openapi: 3.1.0 info: title: KStudeo API version: 0.1.0 summary: Photography booking, USD payments, gallery approval, and referral rewards. description: > Express REST API for the KStudeo portal. Amounts are integer USD cents. A payment success redirect does not confirm payment. Gallery publication does not grant download access. Referral rewards are discounts recorded in an immutable ledger. license: name: Proprietary servers: - url: http://localhost:4000/v1 description: Local API - url: https://api.kstudeo.example/v1 description: Production host, set when ScalaHosting placement is confirmed tags: - name: Auth - name: Catalog - name: Bookings - name: Orders - name: Webhooks - name: Galleries - name: Rewards - name: Notifications - name: Audit security: - bearerAuth: [] paths: /auth/login: post: tags: [Auth] summary: Issue an access token and a refresh token security: [] operationId: login requestBody: required: true content: application/json: schema: type: object additionalProperties: false required: [email, password] properties: email: type: string format: email password: type: string minLength: 10 totp: type: string description: Required for an administrator in production. pattern: "^[0-9]{6}$" responses: "200": description: Access token and refresh token issued. content: application/json: schema: $ref: "#/components/schemas/TokenPair" "401": $ref: "#/components/responses/Error" /auth/logout: post: tags: [Auth] summary: Revoke a refresh token security: [] operationId: logout requestBody: required: true content: application/json: schema: type: object additionalProperties: false required: [refreshToken] properties: refreshToken: type: string responses: "204": description: Refresh token revoked. "401": $ref: "#/components/responses/Error" /auth/refresh: post: tags: [Auth] summary: Rotate the refresh token and issue a new access token security: [] operationId: refreshToken requestBody: required: true content: application/json: schema: type: object additionalProperties: false required: [refreshToken] properties: refreshToken: type: string responses: "200": description: New access token and refresh token. content: application/json: schema: $ref: "#/components/schemas/TokenPair" "401": $ref: "#/components/responses/Error" /auth/me: get: tags: [Auth] summary: Read the user in the access token operationId: getCurrentUser responses: "200": description: Current user. content: application/json: schema: $ref: "#/components/schemas/User" "401": $ref: "#/components/responses/Error" /auth/account-setup: post: tags: [Auth] summary: Consume a single-use account setup token and set a password security: [] operationId: consumeAccountSetup requestBody: required: true content: application/json: schema: type: object additionalProperties: false required: [token, password] properties: token: type: string minLength: 32 password: type: string minLength: 10 responses: "200": description: Account is active. Access token and refresh token are returned. content: application/json: schema: $ref: "#/components/schemas/TokenPair" "409": $ref: "#/components/responses/Error" /auth/password/forgot: post: tags: [Auth] summary: Request a password reset email security: [] operationId: forgotPassword requestBody: required: true content: application/json: schema: type: object additionalProperties: false required: [email] properties: email: type: string format: email responses: "202": description: Accepted. The response does not reveal whether the email exists. /auth/password/reset: post: tags: [Auth] summary: Consume a single-use password reset token security: [] operationId: resetPassword requestBody: required: true content: application/json: schema: type: object additionalProperties: false required: [token, password] properties: token: type: string password: type: string minLength: 10 responses: "204": description: Password updated and refresh tokens revoked. "409": $ref: "#/components/responses/Error" /services: get: tags: [Catalog] summary: List active services and packages security: [] operationId: listServices responses: "200": description: Active catalog. content: application/json: schema: type: object required: [services] properties: services: type: array items: $ref: "#/components/schemas/Service" /admin/services: post: tags: [Catalog] summary: Create a service operationId: createService requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ServiceWrite" responses: "201": description: Created. content: application/json: schema: $ref: "#/components/schemas/Service" "403": $ref: "#/components/responses/Error" /admin/services/{serviceId}/packages: post: tags: [Catalog] summary: Create a package with a USD price operationId: createPackage parameters: - $ref: "#/components/parameters/ServiceId" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/PackageWrite" responses: "201": description: Created. content: application/json: schema: $ref: "#/components/schemas/Package" /admin/packages/{packageId}: patch: tags: [Catalog] summary: Update package price or availability operationId: updatePackage parameters: - $ref: "#/components/parameters/PackageId" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/PackageWrite" responses: "200": description: Updated. Existing order snapshots are unchanged. content: application/json: schema: $ref: "#/components/schemas/Package" /bookings: post: tags: [Bookings] summary: Create a pending booking and order at the catalog price operationId: createBooking parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: type: object additionalProperties: false required: [packageId, scheduledAt, location, fullName, email] properties: packageId: type: string format: uuid scheduledAt: type: string format: date-time location: type: string minLength: 1 fullName: type: string email: type: string format: email phone: type: string referralCode: type: string rewardRedemptionCents: type: integer minimum: 0 description: Requested discount. The server rejects an amount above the ledger balance or the service subtotal. responses: "201": description: Booking and order created in PENDING_PAYMENT. content: application/json: schema: $ref: "#/components/schemas/BookingOrder" "409": $ref: "#/components/responses/Error" get: tags: [Bookings] summary: List the caller's bookings operationId: listBookings responses: "200": description: Bookings owned by the session customer, or all bookings for an administrator. content: application/json: schema: type: object required: [bookings] properties: bookings: type: array items: $ref: "#/components/schemas/Booking" /bookings/{bookingId}: get: tags: [Bookings] summary: Read one booking operationId: getBooking parameters: - $ref: "#/components/parameters/BookingId" responses: "200": description: Booking and its order summary. content: application/json: schema: $ref: "#/components/schemas/BookingOrder" "404": $ref: "#/components/responses/Error" /admin/bookings/{bookingId}/decision: post: tags: [Bookings] summary: Approve or reject a paid booking operationId: decideBooking parameters: - $ref: "#/components/parameters/BookingId" requestBody: required: true content: application/json: schema: type: object additionalProperties: false required: [decision] properties: decision: type: string enum: [approve, reject] note: type: string responses: "200": description: Booking confirmed, or rejected with a refund job queued. content: application/json: schema: $ref: "#/components/schemas/BookingOrder" "422": $ref: "#/components/responses/Error" /orders/{orderId}: get: tags: [Orders] summary: Read an order description: Returns the database status. A checkout success redirect does not change this status. operationId: getOrder parameters: - $ref: "#/components/parameters/OrderId" responses: "200": description: Order. content: application/json: schema: $ref: "#/components/schemas/Order" /orders/{orderId}/checkout: post: tags: [Orders] summary: Create a Stripe checkout in USD operationId: createCheckout parameters: - $ref: "#/components/parameters/OrderId" - $ref: "#/components/parameters/IdempotencyKey" responses: "201": description: Stripe checkout created for the server total. content: application/json: schema: type: object required: [paymentId, provider, amountCents, currency, redirectUrl] properties: paymentId: type: string format: uuid provider: const: STRIPE amountCents: type: integer minimum: 0 currency: const: USD redirectUrl: type: string format: uri /orders/{orderId}/reconcile: post: tags: [Orders] summary: Ask the API to read the payment from Stripe description: The API trusts the Stripe response, not the browser redirect. operationId: reconcileOrder parameters: - $ref: "#/components/parameters/OrderId" responses: "200": description: Current order after reconciliation. content: application/json: schema: $ref: "#/components/schemas/Order" /webhooks/stripe: post: tags: [Webhooks] summary: Stripe webhook security: [] operationId: stripeWebhook parameters: - name: Stripe-Signature in: header required: true schema: type: string requestBody: required: true content: application/json: schema: type: object description: Raw Stripe event. The handler verifies the signature against the raw body. responses: "200": description: Event applied or already processed. "400": $ref: "#/components/responses/Error" /galleries: get: tags: [Galleries] summary: List galleries the customer is allowed to know about description: Pending and revoked galleries are listed without photo URLs. operationId: listGalleries responses: "200": description: Gallery summaries. content: application/json: schema: type: object required: [galleries] properties: galleries: type: array items: $ref: "#/components/schemas/Gallery" /galleries/{galleryId}: get: tags: [Galleries] summary: Read a gallery description: Photo content URLs are omitted unless access is approved and the gallery is published. operationId: getGallery parameters: - $ref: "#/components/parameters/GalleryId" responses: "200": description: Gallery. content: application/json: schema: $ref: "#/components/schemas/Gallery" "404": $ref: "#/components/responses/Error" /galleries/{galleryId}/photos/{photoId}/content-url: post: tags: [Galleries] summary: Issue a five-minute signed URL description: Refused unless the caller owns a published gallery whose access status is APPROVED and the photo is READY. operationId: createPhotoContentUrl parameters: - $ref: "#/components/parameters/GalleryId" - $ref: "#/components/parameters/PhotoId" responses: "200": description: Signed URL. content: application/json: schema: type: object required: [url, expiresAt] properties: url: type: string format: uri expiresAt: type: string format: date-time "403": $ref: "#/components/responses/Error" /galleries/{galleryId}/photos/{photoId}/ratings: post: tags: [Galleries] summary: Rate a photo from 1 to 5 operationId: ratePhoto parameters: - $ref: "#/components/parameters/GalleryId" - $ref: "#/components/parameters/PhotoId" requestBody: required: true content: application/json: schema: type: object additionalProperties: false required: [rating] properties: rating: type: integer minimum: 1 maximum: 5 feedback: type: string maxLength: 2000 responses: "201": description: Rating stored and photographer notified. "403": $ref: "#/components/responses/Error" "409": $ref: "#/components/responses/Error" /admin/galleries: post: tags: [Galleries] summary: Create a draft gallery for a booking operationId: createGallery requestBody: required: true content: application/json: schema: type: object additionalProperties: false required: [bookingId, title] properties: bookingId: type: string format: uuid title: type: string responses: "201": description: Gallery created with publication DRAFT and access PENDING_APPROVAL. content: application/json: schema: $ref: "#/components/schemas/Gallery" /admin/galleries/{galleryId}/photos: post: tags: [Galleries] summary: Upload an original to private storage operationId: uploadPhoto parameters: - $ref: "#/components/parameters/GalleryId" requestBody: required: true content: multipart/form-data: schema: type: object required: [file] properties: file: type: string format: binary responses: "202": description: Original stored and a thumbnail job queued. content: application/json: schema: $ref: "#/components/schemas/Photo" /admin/galleries/{galleryId}/publication: post: tags: [Galleries] summary: Set publication status description: Publishing does not approve customer access. operationId: setPublication parameters: - $ref: "#/components/parameters/GalleryId" requestBody: required: true content: application/json: schema: type: object additionalProperties: false required: [publicationStatus] properties: publicationStatus: type: string enum: [DRAFT, PUBLISHED, ARCHIVED] responses: "200": description: Publication updated. Access status is unchanged. content: application/json: schema: $ref: "#/components/schemas/Gallery" /admin/galleries/{galleryId}/access: post: tags: [Galleries] summary: Approve, reject, or revoke gallery access operationId: decideGalleryAccess parameters: - $ref: "#/components/parameters/GalleryId" requestBody: required: true content: application/json: schema: type: object additionalProperties: false required: [decision] properties: decision: type: string enum: [approve, reject, revoke] reason: type: string responses: "200": description: Access status updated and a review row appended. content: application/json: schema: $ref: "#/components/schemas/Gallery" /referrals/me: get: tags: [Rewards] summary: Read the caller's referral code and attributions operationId: getMyReferral responses: "200": description: Code and reward summaries. content: application/json: schema: $ref: "#/components/schemas/ReferralAccount" /rewards/balance: get: tags: [Rewards] summary: Read the spendable ledger balance operationId: getRewardBalance responses: "200": description: Sum of ledger entries. content: application/json: schema: $ref: "#/components/schemas/RewardBalance" /admin/reward-policies: post: tags: [Rewards] summary: Create a reward policy description: Activating a policy deactivates the previous active policy inside one transaction. operationId: createRewardPolicy requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/RewardPolicyWrite" responses: "201": description: Policy created. content: application/json: schema: $ref: "#/components/schemas/RewardPolicy" /notifications: get: tags: [Notifications] summary: List in-app notifications operationId: listNotifications responses: "200": description: Notifications for the session user. content: application/json: schema: type: object required: [notifications] properties: notifications: type: array items: $ref: "#/components/schemas/Notification" /notifications/{notificationId}/read: post: tags: [Notifications] summary: Mark a notification read operationId: markNotificationRead parameters: - name: notificationId in: path required: true schema: type: string format: uuid responses: "204": description: Marked read. /admin/audit-logs: get: tags: [Audit] summary: Read audit history operationId: listAuditLogs parameters: - name: resourceType in: query schema: type: string - name: resourceId in: query schema: type: string format: uuid responses: "200": description: Append-only audit rows. content: application/json: schema: type: object required: [auditLogs] properties: auditLogs: type: array items: $ref: "#/components/schemas/AuditLog" components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT description: > Short-lived access token. Refresh tokens are posted only to /auth/refresh. Stripe webhooks use the Stripe signature instead. parameters: IdempotencyKey: name: Idempotency-Key in: header required: true schema: type: string minLength: 8 maxLength: 200 BookingId: name: bookingId in: path required: true schema: type: string format: uuid OrderId: name: orderId in: path required: true schema: type: string format: uuid GalleryId: name: galleryId in: path required: true schema: type: string format: uuid PhotoId: name: photoId in: path required: true schema: type: string format: uuid ServiceId: name: serviceId in: path required: true schema: type: string format: uuid PackageId: name: packageId in: path required: true schema: type: string format: uuid responses: Error: description: Request failed. content: application/json: schema: $ref: "#/components/schemas/Error" schemas: Error: type: object additionalProperties: false required: [error] properties: error: type: object additionalProperties: false required: [code, message] properties: code: type: string message: type: string Money: type: object additionalProperties: false required: [amountCents, currency] properties: amountCents: type: integer minimum: 0 currency: const: USD TokenPair: type: object additionalProperties: false required: [accessToken, refreshToken, expiresIn, user] properties: accessToken: type: string description: JWT. Lifetime is expiresIn seconds. refreshToken: type: string description: Returned once. The API stores only its SHA-256 hash. expiresIn: type: integer description: Access-token lifetime in seconds. example: 900 user: $ref: "#/components/schemas/User" User: type: object required: [id, fullName, email, role, status] properties: id: type: string format: uuid fullName: type: string email: type: string format: email phone: type: [string, "null"] role: type: string enum: [CUSTOMER, ADMIN] status: type: string enum: [PENDING_SETUP, ACTIVE, SUSPENDED, DISABLED] Service: type: object required: [id, name, active, packages] properties: id: type: string format: uuid name: type: string description: type: [string, "null"] active: type: boolean packages: type: array items: $ref: "#/components/schemas/Package" ServiceWrite: type: object additionalProperties: false required: [name] properties: name: type: string description: type: string active: type: boolean Package: type: object required: [id, serviceId, name, priceCents, currency, durationMinutes, active] properties: id: type: string format: uuid serviceId: type: string format: uuid name: type: string priceCents: type: integer minimum: 0 currency: const: USD durationMinutes: type: integer minimum: 1 active: type: boolean PackageWrite: type: object additionalProperties: false required: [name, priceCents, durationMinutes] properties: name: type: string priceCents: type: integer minimum: 0 durationMinutes: type: integer minimum: 1 active: type: boolean Booking: type: object required: [id, customerId, packageId, scheduledAt, location, status] properties: id: type: string format: uuid customerId: type: string format: uuid packageId: type: string format: uuid scheduledAt: type: string format: date-time location: type: string status: type: string enum: [PENDING_PAYMENT, PENDING_APPROVAL, CONFIRMED, REJECTED, CANCELLED, COMPLETED] approvedBy: type: [string, "null"] format: uuid approvedAt: type: [string, "null"] format: date-time Order: type: object required: [id, customerId, bookingId, orderNumber, subtotalCents, discountCents, totalCents, currency, status] properties: id: type: string format: uuid customerId: type: string format: uuid bookingId: type: string format: uuid orderNumber: type: string subtotalCents: type: integer minimum: 0 discountCents: type: integer minimum: 0 totalCents: type: integer minimum: 0 currency: const: USD status: type: string enum: [PENDING_PAYMENT, PAID, PAYMENT_FAILED, REFUND_PENDING, PARTIALLY_REFUNDED, REFUNDED, CANCELLED] BookingOrder: type: object required: [booking, order] properties: booking: $ref: "#/components/schemas/Booking" order: $ref: "#/components/schemas/Order" Gallery: type: object required: [id, customerId, bookingId, title, publicationStatus, accessStatus] properties: id: type: string format: uuid customerId: type: string format: uuid bookingId: type: string format: uuid title: type: string publicationStatus: type: string enum: [DRAFT, PUBLISHED, ARCHIVED] accessStatus: type: string enum: [PENDING_APPROVAL, APPROVED, REVOKED] photos: type: array items: $ref: "#/components/schemas/Photo" Photo: type: object required: [id, galleryId, status] properties: id: type: string format: uuid galleryId: type: string format: uuid status: type: string enum: [PENDING_UPLOAD, PROCESSING, READY, FAILED, REMOVED] contentType: type: [string, "null"] byteSize: type: [integer, "null"] ReferralAccount: type: object required: [code, balance] properties: code: type: string balance: $ref: "#/components/schemas/RewardBalance" RewardBalance: type: object required: [availableCents, pendingCents, currency] properties: availableCents: type: integer description: Sum of ledger entries. Pending rewards are excluded. pendingCents: type: integer minimum: 0 description: Calculated rewards that do not yet have a credit. currency: const: USD RewardPolicy: type: object required: [id, percentageBasisPoints, active, validFrom] properties: id: type: string format: uuid percentageBasisPoints: type: integer minimum: 0 maximum: 10000 rewardValidityDays: type: [integer, "null"] active: type: boolean validFrom: type: string format: date-time validUntil: type: [string, "null"] format: date-time RewardPolicyWrite: type: object additionalProperties: false required: [percentageBasisPoints, active, validFrom] properties: percentageBasisPoints: type: integer minimum: 0 maximum: 10000 rewardValidityDays: type: integer minimum: 1 active: type: boolean validFrom: type: string format: date-time validUntil: type: string format: date-time Notification: type: object required: [id, type, payload, createdAt] properties: id: type: string format: uuid type: type: string payload: type: object readAt: type: [string, "null"] format: date-time createdAt: type: string format: date-time AuditLog: type: object required: [id, action, resourceType, resourceId, createdAt] properties: id: type: string format: uuid actorUserId: type: [string, "null"] action: type: string resourceType: type: string resourceId: type: string format: uuid metadata: type: [object, "null"] createdAt: type: string format: date-time