{
 "openapi": "3.1.0",
 "info": {
  "title": "Santa API",
  "version": "1.0.0",
  "summary": "Free Secret Santa generator with exclusions, wish lists and private links, a free alternative to Elfster with no ads.",
  "description": "Create a Secret Santa exchange and draw names in one call, then hand each person their private link. No account and no API key. Creating an exchange returns an owner key for the organizer's routes (x-owner-key header, or ?key=). Each person's private link carries their key after #k=, which opens their page (x-person-key header, or ?key=). By default nobody, including the organizer, can see who gives to whom; the organizer can choose to see it before the draw, and every page then says so. Every device or assistant that opens a person's page is listed on it. Santa sends no emails or texts. Errors are JSON with an error sentence that says what to change. Free. Full guide: https://santa-free.openapps.xyz/llms.txt",
  "contact": {
   "name": "OpenApps",
   "url": "https://free-apps.openapps.xyz/"
  }
 },
 "servers": [
  {
   "url": "https://functions.bounded.sh/apps/c7891873549c9ca1004894b3/api"
  }
 ],
 "externalDocs": {
  "description": "llms.txt",
  "url": "https://santa-free.openapps.xyz/llms.txt"
 },
 "components": {
  "securitySchemes": {
   "ownerKey": {
    "type": "apiKey",
    "in": "header",
    "name": "x-owner-key",
    "description": "The exchange's owner key: 24 letters and digits, from POST /exchanges (ownerKey, or the part after #k= in adminUrl)."
   },
   "ownerKeyQuery": {
    "type": "apiKey",
    "in": "query",
    "name": "key",
    "description": "The owner key as a query parameter, for tools that cannot send headers."
   },
   "personKey": {
    "type": "apiKey",
    "in": "header",
    "name": "x-person-key",
    "description": "A person's key: the part after #k= in their private link."
   },
   "personKeyQuery": {
    "type": "apiKey",
    "in": "query",
    "name": "key",
    "description": "The person's key as a query parameter, for tools that cannot send headers."
   }
  },
  "schemas": {
   "Error": {
    "type": "object",
    "required": [
     "error"
    ],
    "properties": {
     "error": {
      "type": "string",
      "description": "A sentence for a person that says what to change."
     }
    },
    "example": {
     "error": "That owner key does not match this exchange."
    }
   },
   "PersonInput": {
    "oneOf": [
     {
      "type": "string",
      "minLength": 1,
      "maxLength": 60,
      "description": "A name."
     },
     {
      "type": "object",
      "required": [
       "name"
      ],
      "properties": {
       "name": {
        "type": "string",
        "minLength": 1,
        "maxLength": 60
       },
       "contact": {
        "type": "string",
        "maxLength": 120,
        "description": "An email or phone number the organizer uses to reach them. Only the organizer sees it; Santa never messages anyone."
       }
      }
     }
    ]
   },
   "Pair": {
    "type": "array",
    "items": {
     "type": "string"
    },
    "minItems": 2,
    "maxItems": 2,
    "description": "Two names (or person ids) who never draw each other, either way round."
   },
   "CreateExchange": {
    "type": "object",
    "required": [
     "name"
    ],
    "properties": {
     "name": {
      "type": "string",
      "minLength": 1,
      "maxLength": 80
     },
     "budget": {
      "type": "string",
      "maxLength": 40,
      "description": "Free text, like \"$25\" or \"about 20 EUR\"."
     },
     "date": {
      "type": "string",
      "format": "date",
      "description": "The gift day, like 2026-12-18."
     },
     "notes": {
      "type": "string",
      "maxLength": 1000
     },
     "people": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/PersonInput"
      },
      "maxItems": 99,
      "description": "Names must differ (ignoring case)."
     },
     "keepApart": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/Pair"
      },
      "description": "Also accepted as exclusions."
     },
     "draw": {
      "type": "boolean",
      "description": "Draw names right away. Defaults to true when there are 3 or more people."
     },
     "organizerSees": {
      "type": "boolean",
      "default": false,
      "description": "Let the owner key read the full list. Every person's page then says the organizer can see it."
     },
     "revealAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "description": "When everyone sees who gave to whom: a moment in the future, with a time zone offset, like 2026-12-18T19:00:00-05:00. Null for no reveal."
     },
     "allowJoin": {
      "type": "boolean",
      "default": true,
      "description": "People can add themselves with the join link until names are drawn."
     }
    }
   },
   "PersonLink": {
    "type": "object",
    "properties": {
     "id": {
      "type": "string"
     },
     "name": {
      "type": "string"
     },
     "url": {
      "type": "string",
      "format": "uri",
      "description": "Their private link. Send it only to them."
     },
     "message": {
      "type": "string",
      "description": "A ready-made message holding the private link, to copy and send."
     }
    }
   },
   "Opened": {
    "type": "object",
    "properties": {
     "devices": {
      "type": "integer",
      "description": "Browsers that opened this person's page."
     },
     "api": {
      "type": "boolean",
      "description": "Whether an assistant opened it through this API."
     }
    }
   },
   "Match": {
    "type": "object",
    "properties": {
     "from": {
      "type": "string"
     },
     "to": {
      "type": "string"
     }
    }
   },
   "ExchangeCreated": {
    "type": "object",
    "properties": {
     "id": {
      "type": "string"
     },
     "url": {
      "type": "string",
      "format": "uri",
      "description": "The join link. Safe to share with the group."
     },
     "adminUrl": {
      "type": "string",
      "format": "uri",
      "description": "The organizer's page. Keep it private."
     },
     "ownerKey": {
      "type": "string",
      "description": "Keep it private."
     },
     "status": {
      "type": "string",
      "enum": [
       "open",
       "drawn"
      ]
     },
     "people": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/PersonLink"
      }
     },
     "matches": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/Match"
      },
      "description": "Only when organizerSees is true."
     },
     "groupMessage": {
      "type": "string",
      "description": "An invite for the group chat, when people can still join."
     },
     "privacy": {
      "type": "string"
     },
     "nextStep": {
      "type": "string"
     }
    }
   },
   "PublicExchange": {
    "type": "object",
    "properties": {
     "id": {
      "type": "string"
     },
     "url": {
      "type": "string",
      "format": "uri"
     },
     "name": {
      "type": "string"
     },
     "budget": {
      "type": [
       "string",
       "null"
      ]
     },
     "date": {
      "type": [
       "string",
       "null"
      ]
     },
     "notes": {
      "type": [
       "string",
       "null"
      ]
     },
     "status": {
      "type": "string",
      "enum": [
       "open",
       "drawn"
      ]
     },
     "drawnAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "timesDrawn": {
      "type": "integer"
     },
     "joinByLink": {
      "type": "boolean"
     },
     "organizerSeesList": {
      "type": "boolean"
     },
     "revealAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "privacy": {
      "type": "string"
     },
     "people": {
      "type": "array",
      "items": {
       "type": "object",
       "properties": {
        "name": {
         "type": "string"
        },
        "joinedByLink": {
         "type": "boolean"
        },
        "opened": {
         "$ref": "#/components/schemas/Opened"
        }
       }
      }
     }
    }
   },
   "AdminExchange": {
    "allOf": [
     {
      "$ref": "#/components/schemas/PublicExchange"
     },
     {
      "type": "object",
      "properties": {
       "people": {
        "type": "array",
        "items": {
         "type": "object",
         "properties": {
          "id": {
           "type": "string"
          },
          "name": {
           "type": "string"
          },
          "contact": {
           "type": [
            "string",
            "null"
           ]
          },
          "joinedByLink": {
           "type": "boolean"
          },
          "opened": {
           "$ref": "#/components/schemas/Opened"
          },
          "url": {
           "type": [
            "string",
            "null"
           ],
           "description": "Null for people who joined by link: they hold their own key."
          },
          "message": {
           "type": [
            "string",
            "null"
           ]
          }
         }
        }
       },
       "keepApart": {
        "type": "array",
        "items": {
         "$ref": "#/components/schemas/Pair"
        }
       },
       "matches": {
        "type": "array",
        "items": {
         "$ref": "#/components/schemas/Match"
        },
        "description": "Only when the organizer chose to see the list, or after the reveal."
       },
       "groupMessage": {
        "type": "string"
       },
       "note": {
        "type": "string"
       }
      }
     }
    ]
   },
   "UpdateExchange": {
    "type": "object",
    "properties": {
     "name": {
      "type": "string",
      "maxLength": 80
     },
     "budget": {
      "type": "string",
      "maxLength": 40
     },
     "date": {
      "type": "string",
      "format": "date"
     },
     "notes": {
      "type": "string",
      "maxLength": 1000
     },
     "allowJoin": {
      "type": "boolean"
     },
     "organizerSees": {
      "type": "boolean",
      "description": "Only before the draw."
     },
     "revealAt": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time",
      "description": "A moment in the future, or null. After the draw, only later or null. Once the reveal has passed, it cannot change."
     },
     "keepApart": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/Pair"
      }
     }
    }
   },
   "PersonPage": {
    "type": "object",
    "properties": {
     "id": {
      "type": "string"
     },
     "name": {
      "type": "string"
     },
     "exchange": {
      "$ref": "#/components/schemas/PublicExchange"
     },
     "givesTo": {
      "type": [
       "object",
       "null"
      ],
      "description": "Null until names are drawn.",
      "properties": {
       "name": {
        "type": "string"
       },
       "wishList": {
        "type": [
         "string",
         "null"
        ]
       },
       "questions": {
        "type": "array",
        "items": {
         "type": "object",
         "properties": {
          "question": {
           "type": "string"
          },
          "answer": {
           "type": [
            "string",
            "null"
           ]
          }
         }
        }
       }
      }
     },
     "waiting": {
      "type": "string"
     },
     "wishList": {
      "type": [
       "string",
       "null"
      ]
     },
     "questionsForYou": {
      "type": "array",
      "items": {
       "type": "object",
       "properties": {
        "questionId": {
         "type": "string"
        },
        "question": {
         "type": "string"
        },
        "answer": {
         "type": [
          "string",
          "null"
         ]
        }
       }
      }
     },
     "yourSecretSanta": {
      "type": "string",
      "description": "Only after the reveal."
     },
     "openedOn": {
      "$ref": "#/components/schemas/Opened"
     },
     "privacy": {
      "type": "string"
     }
    }
   }
  }
 },
 "paths": {
  "/": {
   "get": {
    "operationId": "listRoutes",
    "summary": "List the routes",
    "responses": {
     "200": {
      "description": "The routes and where the docs are",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     }
    }
   }
  },
  "/exchanges": {
   "post": {
    "operationId": "createExchange",
    "summary": "Create an exchange and draw names (no key needed)",
    "description": "Makes the exchange with its people and pairs to keep apart, and draws names when there are 3 or more people. Returns the join link, the organizer's admin link and owner key, and each person's private link with a ready-made message. Send each person only their own link. If the pairs make a draw impossible, it answers 409 naming who is stuck, and nothing is created.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/CreateExchange"
       },
       "example": {
        "name": "Office Secret Santa",
        "budget": "$25",
        "date": "2026-12-18",
        "notes": "Swap gifts at the team lunch.",
        "people": [
         "Ana",
         "Ben",
         {
          "name": "Cy",
          "contact": "cy@example.com"
         },
         "Dee",
         "Eli"
        ],
        "keepApart": [
         [
          "Ana",
          "Ben"
         ]
        ]
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "The new exchange",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ExchangeCreated"
        },
        "example": {
         "id": "k3XqP9mT2vRzW8LwN4hY",
         "url": "https://santa-free.openapps.xyz/x/k3XqP9mT2vRzW8LwN4hY",
         "adminUrl": "https://santa-free.openapps.xyz/x/k3XqP9mT2vRzW8LwN4hY/admin#k=Qm7tR2xV9pL4nK8sW3zH6jYc",
         "ownerKey": "Qm7tR2xV9pL4nK8sW3zH6jYc",
         "status": "drawn",
         "people": [
          {
           "id": "ozanFtSj43SiqzcgSSeg",
           "name": "Ana",
           "url": "https://santa-free.openapps.xyz/x/k3XqP9mT2vRzW8LwN4hY/p/ozanFtSj43SiqzcgSSeg#k=Vb4nT8kQ2mXw7Lp3Rz9Hs6Dc",
           "message": "Hi Ana! Names are drawn for Office Secret Santa. Your private link shows who you are getting a gift for, so keep it to yourself: https://santa-free.openapps.xyz/x/k3XqP9mT2vRzW8LwN4hY/p/ozanFtSj43SiqzcgSSeg#k=Vb4nT8kQ2mXw7Lp3Rz9Hs6Dc\nBudget: $25. Gifts on Friday, December 18."
          }
         ],
         "privacy": "Nobody can see the full list of who gives to whom, not even the organizer. Each person's private link shows only their own match.",
         "nextStep": "Santa sends no emails or texts. Send each person their own url (the message is ready to copy): it shows who they give to. Do not open other people's urls: each page lists every device that opened it. Keep adminUrl and ownerKey private."
        }
       }
      }
     },
     "400": {
      "description": "The body is not a JSON object, or a field does not fit (the error says which, for example \"Two people are called Ana. Add a last initial so everyone can tell them apart.\")",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "409": {
      "description": "No draw can follow the pairs kept apart; the error names the people who are stuck. Nothing was created.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/exchanges/{exchangeId}": {
   "get": {
    "operationId": "getExchange",
    "summary": "Read the exchange as everyone in it sees it (never matches)",
    "parameters": [
     {
      "name": "exchangeId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "pattern": "^[A-Za-z0-9]{8,32}$"
      },
      "description": "The exchange id: the last part of the join link."
     }
    ],
    "responses": {
     "200": {
      "description": "The exchange",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/PublicExchange"
        },
        "example": {
         "id": "k3XqP9mT2vRzW8LwN4hY",
         "url": "https://santa-free.openapps.xyz/x/k3XqP9mT2vRzW8LwN4hY",
         "name": "Office Secret Santa",
         "budget": "$25",
         "date": "2026-12-18",
         "notes": "Swap gifts at the team lunch.",
         "status": "drawn",
         "drawnAt": "2026-11-20T15:04:05.000Z",
         "timesDrawn": 1,
         "joinByLink": false,
         "organizerSeesList": false,
         "revealAt": null,
         "privacy": "Nobody can see the full list of who gives to whom, not even the organizer. Each person's private link shows only their own match.",
         "people": [
          {
           "name": "Ana",
           "joinedByLink": false,
           "opened": {
            "devices": 1,
            "api": false
           }
          },
          {
           "name": "Ben",
           "joinedByLink": false,
           "opened": {
            "devices": 0,
            "api": false
           }
          }
         ]
        }
       }
      }
     },
     "404": {
      "description": "No exchange with that id.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   },
   "patch": {
    "operationId": "updateExchange",
    "summary": "Change the exchange's details, pairs, privacy choice or reveal",
    "parameters": [
     {
      "name": "exchangeId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "pattern": "^[A-Za-z0-9]{8,32}$"
      },
      "description": "The exchange id: the last part of the join link."
     }
    ],
    "security": [
     {
      "ownerKey": []
     },
     {
      "ownerKeyQuery": []
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/UpdateExchange"
       },
       "example": {
        "budget": "$30",
        "notes": "Swap gifts at the team lunch on Friday.",
        "keepApart": [
         [
          "Ana",
          "Ben"
         ],
         [
          "Cy",
          "Eli"
         ]
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "The exchange after the change",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/PublicExchange"
        }
       }
      }
     },
     "401": {
      "description": "No owner key.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "403": {
      "description": "Wrong owner key.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "409": {
      "description": "Names are drawn, so organizerSees cannot change and the reveal can only move later. Once the reveal has passed, the draw is final and the reveal cannot change.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/exchanges/{exchangeId}/admin": {
   "get": {
    "operationId": "getExchangeAdmin",
    "summary": "The organizer's view: people, contacts, private links, pairs, who opened their page",
    "parameters": [
     {
      "name": "exchangeId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "pattern": "^[A-Za-z0-9]{8,32}$"
      },
      "description": "The exchange id: the last part of the join link."
     }
    ],
    "security": [
     {
      "ownerKey": []
     },
     {
      "ownerKeyQuery": []
     }
    ],
    "responses": {
     "200": {
      "description": "Everything the organizer may see",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/AdminExchange"
        }
       }
      }
     },
     "401": {
      "description": "No owner key.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "403": {
      "description": "Wrong owner key.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "404": {
      "description": "No exchange with that id.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/exchanges/{exchangeId}/people": {
   "post": {
    "operationId": "addPeople",
    "summary": "Add people before the draw",
    "parameters": [
     {
      "name": "exchangeId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "pattern": "^[A-Za-z0-9]{8,32}$"
      },
      "description": "The exchange id: the last part of the join link."
     }
    ],
    "security": [
     {
      "ownerKey": []
     },
     {
      "ownerKeyQuery": []
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "people"
        ],
        "properties": {
         "people": {
          "type": "array",
          "items": {
           "$ref": "#/components/schemas/PersonInput"
          }
         }
        }
       },
       "example": {
        "people": [
         "Fay",
         {
          "name": "Gus",
          "contact": "+1 555 0100"
         }
        ]
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "The new people with their links",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "added": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/PersonLink"
           }
          },
          "count": {
           "type": "integer"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "A name is missing, too long or already taken.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "409": {
      "description": "Names are drawn; reopen first.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/exchanges/{exchangeId}/people/{personId}": {
   "delete": {
    "operationId": "removePerson",
    "summary": "Remove a person before the draw",
    "parameters": [
     {
      "name": "exchangeId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "pattern": "^[A-Za-z0-9]{8,32}$"
      },
      "description": "The exchange id: the last part of the join link."
     },
     {
      "name": "personId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "pattern": "^[A-Za-z0-9]{8,32}$"
      },
      "description": "A person's id: the part after /p/ in their private link."
     }
    ],
    "security": [
     {
      "ownerKey": []
     },
     {
      "ownerKeyQuery": []
     }
    ],
    "responses": {
     "200": {
      "description": "Removed",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "removed": {
           "type": "string"
          },
          "count": {
           "type": "integer"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "No such person.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "409": {
      "description": "Names are drawn; reopen first.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/exchanges/{exchangeId}/draw": {
   "post": {
    "operationId": "drawNames",
    "summary": "Draw names (or draw again)",
    "parameters": [
     {
      "name": "exchangeId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "pattern": "^[A-Za-z0-9]{8,32}$"
      },
      "description": "The exchange id: the last part of the join link."
     }
    ],
    "security": [
     {
      "ownerKey": []
     },
     {
      "ownerKeyQuery": []
     }
    ],
    "description": "Draws names for everyone in the exchange, keeping the pairs apart. Every valid draw is equally likely. If names are already drawn, send {\"again\": true}: every match changes and every page says names were drawn again.",
    "requestBody": {
     "required": false,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "again": {
          "type": "boolean"
         }
        }
       },
       "example": {
        "again": true
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Drawn. matches is present only when the organizer chose to see the list.",
      "content": {
       "application/json": {
        "schema": {
         "allOf": [
          {
           "$ref": "#/components/schemas/PublicExchange"
          },
          {
           "type": "object",
           "properties": {
            "matches": {
             "type": "array",
             "items": {
              "$ref": "#/components/schemas/Match"
             }
            },
            "groupMessage": {
             "type": "string"
            },
            "nextStep": {
             "type": "string"
            }
           }
          }
         ]
        }
       }
      }
     },
     "409": {
      "description": "Fewer than 3 people, already drawn without again, no draw can follow the pairs (the error names who is stuck), the reveal time passed before the draw (move it later or turn it off first), or the reveal has passed and the draw is final.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/exchanges/{exchangeId}/reopen": {
   "post": {
    "operationId": "reopenExchange",
    "summary": "Undo the draw so people and pairs can change",
    "parameters": [
     {
      "name": "exchangeId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "pattern": "^[A-Za-z0-9]{8,32}$"
      },
      "description": "The exchange id: the last part of the join link."
     }
    ],
    "security": [
     {
      "ownerKey": []
     },
     {
      "ownerKeyQuery": []
     }
    ],
    "responses": {
     "200": {
      "description": "The exchange, open again",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/PublicExchange"
        }
       }
      }
     },
     "409": {
      "description": "The reveal has passed, so the draw is final.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/exchanges/{exchangeId}/calendar.ics": {
   "get": {
    "operationId": "getCalendar",
    "summary": "A calendar file for the gift day, with a reminder a week before",
    "parameters": [
     {
      "name": "exchangeId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "pattern": "^[A-Za-z0-9]{8,32}$"
      },
      "description": "The exchange id: the last part of the join link."
     }
    ],
    "responses": {
     "200": {
      "description": "An iCalendar file",
      "content": {
       "text/calendar": {
        "schema": {
         "type": "string"
        }
       }
      }
     },
     "404": {
      "description": "No exchange, or no gift day set.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/people/{personId}": {
   "get": {
    "operationId": "getPersonPage",
    "summary": "One person's page: who they give to, wish lists and questions",
    "parameters": [
     {
      "name": "personId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "pattern": "^[A-Za-z0-9]{8,32}$"
      },
      "description": "A person's id: the part after /p/ in their private link."
     }
    ],
    "security": [
     {
      "personKey": []
     },
     {
      "personKeyQuery": []
     }
    ],
    "description": "Opening a page through the API is recorded on it as opened by an assistant, like any device, with the time of the latest call. Only call it for the person you act for.",
    "responses": {
     "200": {
      "description": "The person's page",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/PersonPage"
        },
        "example": {
         "id": "ozanFtSj43SiqzcgSSeg",
         "name": "Ana",
         "exchange": {
          "id": "k3XqP9mT2vRzW8LwN4hY",
          "url": "https://santa-free.openapps.xyz/x/k3XqP9mT2vRzW8LwN4hY",
          "name": "Office Secret Santa",
          "budget": "$25",
          "date": "2026-12-18",
          "notes": "Swap gifts at the team lunch.",
          "status": "drawn",
          "drawnAt": "2026-11-20T15:04:05.000Z",
          "timesDrawn": 1,
          "joinByLink": false,
          "organizerSeesList": false,
          "revealAt": null,
          "privacy": "Nobody can see the full list of who gives to whom, not even the organizer. Each person's private link shows only their own match.",
          "people": [
           {
            "name": "Ana",
            "joinedByLink": false,
            "opened": {
             "devices": 1,
             "api": false
            }
           },
           {
            "name": "Ben",
            "joinedByLink": false,
            "opened": {
             "devices": 0,
             "api": false
            }
           }
          ]
         },
         "givesTo": {
          "name": "Cy",
          "wishList": "Coffee beans\nA good novel",
          "questions": [
           {
            "question": "What size are you?",
            "answer": "Medium"
           }
          ]
         },
         "wishList": "Tea, a good novel",
         "questionsForYou": [],
         "openedOn": {
          "devices": 0,
          "api": true
         },
         "privacy": "Nobody can see the full list of who gives to whom, not even the organizer. Each person's private link shows only their own match."
        }
       }
      }
     },
     "401": {
      "description": "No person key.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "403": {
      "description": "Wrong person key.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "404": {
      "description": "No person with that id.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "410": {
      "description": "This person was removed.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/people/{personId}/wishlist": {
   "put": {
    "operationId": "setWishList",
    "summary": "Write the person's wish list",
    "parameters": [
     {
      "name": "personId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "pattern": "^[A-Za-z0-9]{8,32}$"
      },
      "description": "A person's id: the part after /p/ in their private link."
     }
    ],
    "security": [
     {
      "personKey": []
     },
     {
      "personKeyQuery": []
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "text"
        ],
        "properties": {
         "text": {
          "type": "string",
          "maxLength": 2000
         }
        }
       },
       "example": {
        "text": "Wool socks, size 9\nAnything by Ursula K. Le Guin"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Saved",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "wishList": {
           "type": [
            "string",
            "null"
           ]
          }
         }
        }
       }
      }
     }
    }
   }
  },
  "/people/{personId}/questions": {
   "post": {
    "operationId": "askQuestion",
    "summary": "Ask the person's giftee a question anonymously",
    "parameters": [
     {
      "name": "personId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "pattern": "^[A-Za-z0-9]{8,32}$"
      },
      "description": "A person's id: the part after /p/ in their private link."
     }
    ],
    "security": [
     {
      "personKey": []
     },
     {
      "personKeyQuery": []
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "text"
        ],
        "properties": {
         "text": {
          "type": "string",
          "minLength": 1,
          "maxLength": 300
         }
        }
       },
       "example": {
        "text": "Tea or coffee?"
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "Asked",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "questionId": {
           "type": "string"
          },
          "question": {
           "type": "string"
          },
          "note": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "409": {
      "description": "Names are not drawn yet.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/people/{personId}/answers": {
   "post": {
    "operationId": "answerQuestion",
    "summary": "Answer a question from the person's Secret Santa",
    "parameters": [
     {
      "name": "personId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "pattern": "^[A-Za-z0-9]{8,32}$"
      },
      "description": "A person's id: the part after /p/ in their private link."
     }
    ],
    "security": [
     {
      "personKey": []
     },
     {
      "personKeyQuery": []
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "questionId",
         "answer"
        ],
        "properties": {
         "questionId": {
          "type": "string"
         },
         "answer": {
          "type": "string",
          "maxLength": 500
         }
        }
       },
       "example": {
        "questionId": "y9wsYqy4PkWC",
        "answer": "Coffee"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Answered",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "questionId": {
           "type": "string"
          },
          "question": {
           "type": "string"
          },
          "answer": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "No such question for this person.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/questions/{questionId}": {
   "post": {
    "operationId": "sendWaitingQuestion",
    "summary": "Send a question a Secret Santa's browser left in askrequests",
    "description": "The web app's way to ask anonymously: every reader sees who wrote a document, so the Santa's browser writes askrequests/<questionId> and this route writes the question to the giftee as the API. Assistants ask with POST /people/{personId}/questions instead.",
    "parameters": [
     {
      "name": "questionId",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "pattern": "^[A-Za-z0-9]{8,32}$"
      },
      "description": "The id of the askrequests document the browser wrote."
     }
    ],
    "responses": {
     "201": {
      "description": "Sent",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "questionId": {
           "type": "string"
          },
          "question": {
           "type": "string"
          }
         }
        },
        "example": {
         "questionId": "Tq8vK2mWx4Pz",
         "question": "Tea or coffee?"
        }
       }
      }
     },
     "404": {
      "description": "No question waiting with that id.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "409": {
      "description": "Already sent, or the draw changed since it was written.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  }
 }
}
