{
  "openapi": "3.1.0",
  "info": {
    "title": "Lappitude Booking API",
    "version": "1.1.0",
    "summary": "Live availability, prices and booking for Lappitude experiences in Rovaniemi, Lapland.",
    "description": "Public endpoints for checking availability and prices of Lappitude's guided experiences and villa stays, and for booking an experience on behalf of a user. No authentication is required. The user always completes the booking themselves, either by paying on a Stripe page or by confirming from an email. Villa stays are booked by enquiry on https://lappitude.fi.",
    "contact": {
      "name": "Lappitude Oy",
      "email": "contact@lappitude.fi",
      "url": "https://lappitude.fi"
    }
  },
  "externalDocs": {
    "description": "API documentation",
    "url": "https://lappitude.fi/developers/"
  },
  "servers": [
    {
      "url": "https://api.lappitude.fi"
    }
  ],
  "security": [],
  "tags": [
    {
      "name": "Experiences",
      "description": "Guided activities such as Northern Lights hunting and ice fishing."
    },
    {
      "name": "Booking",
      "description": "Book an experience for a user."
    },
    {
      "name": "Villas",
      "description": "Private villa stays."
    },
    {
      "name": "Status",
      "description": "Service health."
    }
  ],
  "paths": {
    "/api/activities/{activityId}/dates/{month}/{year}": {
      "get": {
        "operationId": "getActivityCalendar",
        "summary": "Activity availability for a month",
        "description": "Returns every day of the month with its booking status and departure time slots. Dates and times are Helsinki local time.",
        "tags": [
          "Experiences"
        ],
        "parameters": [
          {
            "name": "activityId",
            "in": "path",
            "required": true,
            "description": "Activity id. 1 = Private Northern Lights Hunting; 2 = Private Snowshoeing Experience; 3 = Private Ice Fishing Experience; 5 = Private Hiking in Autumn; 6 = Small Group Northern Lights Hunting; 7 = Small Group Ice Fishing Experience; 8 = Small Group Snowshoeing Experience.",
            "schema": {
              "type": "integer",
              "enum": [
                1,
                2,
                3,
                5,
                6,
                7,
                8
              ]
            }
          },
          {
            "name": "month",
            "in": "path",
            "required": true,
            "description": "Month, 1-12.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 12
            }
          },
          {
            "name": "year",
            "in": "path",
            "required": true,
            "description": "Four-digit year, 2020 or later.",
            "schema": {
              "type": "integer",
              "minimum": 2020
            }
          }
        ],
        "responses": {
          "200": {
            "description": "One entry per day of the month.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ActivityDateAvailability"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid parameters. The body is a plain-text error message.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/api/activities/{activityId}/{participants}/{month}/{year}": {
      "get": {
        "operationId": "getActivityInfo",
        "summary": "Activity details and price for a group size",
        "description": "Returns the activity description, duration, participant limits and prices valid in the given month. Private activities charge one price for the whole group; small group activities charge the price per person. Prices are in EUR and include VAT.",
        "tags": [
          "Experiences"
        ],
        "parameters": [
          {
            "name": "activityId",
            "in": "path",
            "required": true,
            "description": "Activity id. 1 = Private Northern Lights Hunting; 2 = Private Snowshoeing Experience; 3 = Private Ice Fishing Experience; 5 = Private Hiking in Autumn; 6 = Small Group Northern Lights Hunting; 7 = Small Group Ice Fishing Experience; 8 = Small Group Snowshoeing Experience.",
            "schema": {
              "type": "integer",
              "enum": [
                1,
                2,
                3,
                5,
                6,
                7,
                8
              ]
            }
          },
          {
            "name": "participants",
            "in": "path",
            "required": true,
            "description": "Number of participants in the group.",
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          },
          {
            "name": "month",
            "in": "path",
            "required": true,
            "description": "Month, 1-12.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 12
            }
          },
          {
            "name": "year",
            "in": "path",
            "required": true,
            "description": "Four-digit year, 2020 or later.",
            "schema": {
              "type": "integer",
              "minimum": 2020
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Activity details.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ActivityInfo"
                }
              }
            }
          },
          "400": {
            "description": "Invalid parameters. The body is a plain-text error message.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/api/activities/payment-options": {
      "get": {
        "operationId": "getPaymentOptions",
        "summary": "Available payment options",
        "description": "Tells whether paying on site is currently offered in addition to paying online.",
        "tags": [
          "Experiences"
        ],
        "responses": {
          "200": {
            "description": "Payment options.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "allowPayOnSite": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "allowPayOnSite"
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/api/villas/{villaId}/availability": {
      "get": {
        "operationId": "getVillaAvailability",
        "summary": "Villa availability",
        "description": "Returns, for each month in the range, whether the villa takes bookings and which nights are already taken. Villa stays are booked by enquiry on lappitude.fi.",
        "tags": [
          "Villas"
        ],
        "parameters": [
          {
            "name": "villaId",
            "in": "path",
            "required": true,
            "description": "Villa id: villa-tolonen, villa-manhattan.",
            "schema": {
              "type": "string",
              "enum": [
                "villa-tolonen",
                "villa-manhattan"
              ]
            }
          },
          {
            "name": "fromYear",
            "in": "query",
            "required": true,
            "description": "Year of the first month in the range.",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "fromMonth",
            "in": "query",
            "required": true,
            "description": "First month in the range, 1-12.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 12
            }
          },
          {
            "name": "monthsCount",
            "in": "query",
            "required": false,
            "description": "Number of months to return, 1-36. Defaults to 12.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 36,
              "default": 12
            }
          }
        ],
        "responses": {
          "200": {
            "description": "One entry per month.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/VillaAvailabilityMonth"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid parameters. The body is a plain-text error message.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/api/activities/booking/agent": {
      "post": {
        "operationId": "createAgentBooking",
        "summary": "Book an experience for a user",
        "description": "Books a departure for the user you act for. Pick an open slot first with the calendar endpoint and send its date and time as bookedForDate. With paymentOption online, the response has a checkoutUrl, which we also email to the user: give it to them; they pay on a secure Stripe page within 30 minutes, after which the booking is confirmed. With on_site (only while getPaymentOptions allows it), we email the user a link and the booking counts only once they confirm it, within 2 hours. Always use the user's own name, email and phone number.",
        "tags": [
          "Booking"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AgentBookingRequest"
              },
              "example": {
                "activityId": 1,
                "bookedForDate": "2026-12-15T19:00:00+02:00",
                "numberOfAdults": 2,
                "numberOfChildren": 0,
                "firstName": "Anna",
                "lastName": "Virtanen",
                "email": "anna.virtanen@example.com",
                "phoneNumber": "+358401234567",
                "pickupLocation": "Hotel name, Rovaniemi city centre",
                "specialRequests": "Celebrating an anniversary",
                "paymentOption": "online"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Booking created and waiting for payment or email confirmation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AgentBookingResult"
                },
                "example": {
                  "reference": "281215417",
                  "paymentOption": "online",
                  "total": 700,
                  "holdExpiresAt": "2026-12-01T10:32:00Z",
                  "checkoutUrl": "https://checkout.stripe.com/c/pay/cs_live_...",
                  "sessionId": "cs_live_...",
                  "confirmationRequired": false,
                  "message": "Give the checkout URL to the customer; we also emailed it to anna.virtanen@example.com. The booking is confirmed once they pay, which must happen within 30 minutes."
                }
              }
            }
          },
          "400": {
            "description": "The booking was not made. The body lists the reasons, e.g. the slot is full or a field is missing.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                }
              }
            }
          },
          "429": {
            "description": "Too many booking attempts from this address. Try again later."
          }
        }
      }
    },
    "/api/activities/booking/status": {
      "get": {
        "operationId": "getBookingStatus",
        "summary": "Payment status of an online booking",
        "description": "Tells whether the user has paid for a booking made with paymentOption online. Use the sessionId from the booking response.",
        "tags": [
          "Booking"
        ],
        "parameters": [
          {
            "name": "sessionId",
            "in": "query",
            "required": true,
            "description": "sessionId returned when the booking was made.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Current status.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BookingStatus"
                }
              }
            }
          },
          "400": {
            "description": "Invalid parameters. The body is a plain-text error message.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "404": {
            "description": "No booking has this session."
          }
        }
      }
    },
    "/health": {
      "get": {
        "operationId": "getHealth",
        "summary": "Health check",
        "description": "Returns 200 when the API is running.",
        "tags": [
          "Status"
        ],
        "responses": {
          "200": {
            "description": "The API is up.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "const": "ok"
                    }
                  },
                  "required": [
                    "status"
                  ]
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "TimeSlotAvailability": {
        "type": "object",
        "properties": {
          "timeSlot": {
            "type": "string",
            "description": "Departure time, HH:mm Helsinki time.",
            "examples": [
              "19:00"
            ]
          },
          "isBooked": {
            "type": "boolean",
            "description": "True when the slot cannot be booked."
          },
          "availabilityStatus": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "available",
              "almost-full",
              "fully-booked",
              null
            ],
            "description": "Small group activities only."
          },
          "bookedCount": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Small group activities only."
          },
          "maxParticipants": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Small group activities only."
          }
        },
        "required": [
          "timeSlot",
          "isBooked"
        ]
      },
      "ActivityDateAvailability": {
        "type": "object",
        "properties": {
          "date": {
            "type": "string",
            "format": "date",
            "description": "yyyy-MM-dd"
          },
          "bookingStatus": {
            "type": "string",
            "enum": [
              "available",
              "almost-full",
              "booked",
              "not-available"
            ]
          },
          "timeSlots": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TimeSlotAvailability"
            }
          }
        },
        "required": [
          "date",
          "bookingStatus",
          "timeSlots"
        ]
      },
      "ActivityInfo": {
        "type": "object",
        "properties": {
          "activityPriceId": {
            "type": "integer"
          },
          "activityName": {
            "type": "string"
          },
          "description": {
            "type": "string"
          },
          "duration": {
            "type": "integer",
            "description": "Duration in hours."
          },
          "originalPrice": {
            "type": "number",
            "description": "Price for the requested group size, EUR."
          },
          "price": {
            "type": "object",
            "properties": {
              "adult": {
                "type": "number"
              },
              "child": {
                "type": "number"
              }
            }
          },
          "maxParticipants": {
            "type": "integer"
          },
          "minParticipants": {
            "type": "integer"
          },
          "bookingType": {
            "type": "string",
            "enum": [
              "private",
              "small_group"
            ]
          },
          "minAge": {
            "type": [
              "integer",
              "null"
            ]
          },
          "groupPricing": {
            "type": "array",
            "description": "All price tiers by group size.",
            "items": {
              "type": "object",
              "properties": {
                "minGroupSize": {
                  "type": "integer"
                },
                "maxGroupSize": {
                  "type": "integer"
                },
                "price": {
                  "type": "number"
                }
              }
            }
          }
        }
      },
      "AgentBookingRequest": {
        "type": "object",
        "properties": {
          "activityId": {
            "type": "integer",
            "enum": [
              1,
              2,
              3,
              5,
              6,
              7,
              8
            ],
            "description": "Activity id. 1 = Private Northern Lights Hunting; 2 = Private Snowshoeing Experience; 3 = Private Ice Fishing Experience; 5 = Private Hiking in Autumn; 6 = Small Group Northern Lights Hunting; 7 = Small Group Ice Fishing Experience; 8 = Small Group Snowshoeing Experience."
          },
          "bookedForDate": {
            "type": "string",
            "format": "date-time",
            "description": "Start of the chosen slot in Helsinki time with its offset, e.g. 2026-12-15T19:00:00+02:00."
          },
          "numberOfAdults": {
            "type": "integer",
            "minimum": 0
          },
          "numberOfChildren": {
            "type": "integer",
            "minimum": 0
          },
          "childrenAges": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "description": "Required when the activity has a minimum age and children are included."
          },
          "firstName": {
            "type": "string"
          },
          "lastName": {
            "type": "string"
          },
          "email": {
            "type": "string",
            "format": "email",
            "description": "The user's own email. Confirmations go here."
          },
          "phoneNumber": {
            "type": "string",
            "description": "Include the country code, e.g. +358 40 1234567."
          },
          "pickupLocation": {
            "type": "string",
            "description": "Hotel or address in Rovaniemi or Ivalo where the guide picks the group up."
          },
          "specialRequests": {
            "type": "string"
          },
          "paymentOption": {
            "type": "string",
            "enum": [
              "online",
              "on_site"
            ],
            "default": "online"
          },
          "partnerCode": {
            "type": "string",
            "description": "Optional partner referral code."
          }
        },
        "required": [
          "activityId",
          "bookedForDate",
          "numberOfAdults",
          "numberOfChildren",
          "firstName",
          "lastName",
          "email",
          "phoneNumber",
          "pickupLocation"
        ]
      },
      "AgentBookingResult": {
        "type": "object",
        "properties": {
          "reference": {
            "type": "string",
            "description": "Booking reference to share with the user."
          },
          "paymentOption": {
            "type": "string",
            "enum": [
              "online",
              "on_site"
            ]
          },
          "total": {
            "type": "number",
            "description": "Total price in EUR, VAT included."
          },
          "holdExpiresAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "The slot is held until this time (UTC) while waiting for payment or confirmation."
          },
          "checkoutUrl": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "Online only: Stripe payment page for the user."
          },
          "sessionId": {
            "type": [
              "string",
              "null"
            ],
            "description": "Online only: use with getBookingStatus."
          },
          "confirmationRequired": {
            "type": "boolean",
            "description": "True for on_site: the user must confirm from the email we sent."
          },
          "message": {
            "type": [
              "string",
              "null"
            ],
            "description": "What to tell the user next."
          }
        },
        "required": [
          "reference",
          "paymentOption",
          "total",
          "confirmationRequired"
        ]
      },
      "BookingStatus": {
        "type": "object",
        "properties": {
          "state": {
            "type": "string",
            "enum": [
              "confirmed",
              "processing",
              "open",
              "expired"
            ],
            "description": "confirmed: paid and booked. processing: payment is being processed. open: not paid yet. expired: the hold lapsed."
          },
          "reference": {
            "type": "string"
          },
          "activityName": {
            "type": "string"
          },
          "startTime": {
            "type": "string",
            "format": "date-time"
          },
          "numAdults": {
            "type": "integer"
          },
          "numChildren": {
            "type": "integer"
          },
          "total": {
            "type": "number"
          },
          "paymentStatus": {
            "type": "string",
            "enum": [
              "unpaid",
              "paid",
              "refunded"
            ]
          },
          "holdExpiresAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        },
        "required": [
          "state",
          "reference"
        ]
      },
      "VillaAvailabilityMonth": {
        "type": "object",
        "properties": {
          "year": {
            "type": "integer"
          },
          "month": {
            "type": "integer"
          },
          "isOpen": {
            "type": "boolean",
            "description": "Whether the villa takes bookings this month."
          },
          "blockedDates": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "date"
            },
            "description": "Nights already taken, yyyy-MM-dd. Only filled for open months."
          }
        },
        "required": [
          "year",
          "month",
          "isOpen",
          "blockedDates"
        ]
      }
    }
  }
}