{
  "openapi": "3.1.0",
  "info": {
    "title": "Legwork",
    "version": "1.0.0",
    "description": "Street marketing for iPhone apps in London, paid per first-time download the App Store counts. Book, hold the budget on a card, add the campaign link, report counts; or join as a marketer and take spots. Also an MCP server at https://legwork.spacedrecall.app/api/mcp. No API key: a booking token or marketer token, returned when you book or join, is the key.",
    "contact": {
      "email": "support@spacedrecall.app"
    }
  },
  "servers": [
    {
      "url": "https://legwork.spacedrecall.app/api"
    }
  ],
  "paths": {
    "/pricing": {
      "get": {
        "operationId": "pricing",
        "summary": "The rules: prices, budgets, areas, times, how the card hold works",
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/apps": {
      "get": {
        "operationId": "findApp",
        "summary": "Find an iPhone app on the UK App Store",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "App name, App Store link or numeric id"
          }
        ],
        "responses": {
          "200": {
            "description": "Matches",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "apps": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/App"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "A problem, with a message to show.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/bookings": {
      "post": {
        "operationId": "createBooking",
        "summary": "Book street marketers for an app",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/NewBooking"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Saved. Give pay_url to the person to hold the budget on their card; keep booking_token.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Booking"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "booking_token": {
                          "type": "string"
                        },
                        "pay_url": {
                          "type": [
                            "string",
                            "null"
                          ]
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "A problem, with a message to show.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "A problem, with a message to show.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/bookings/resend": {
      "post": {
        "operationId": "resendBookingLinks",
        "summary": "Email fresh links to every booking made with an email",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Always ok, whether or not there are bookings"
          }
        }
      }
    },
    "/bookings/{booking_token}": {
      "get": {
        "operationId": "getBooking",
        "summary": "A booking as it stands",
        "parameters": [
          {
            "name": "booking_token",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^lwb_[A-Za-z0-9_-]{32}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Booking"
                }
              }
            }
          },
          "404": {
            "description": "A problem, with a message to show.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/bookings/{booking_token}/pay": {
      "get": {
        "operationId": "payForBooking",
        "summary": "For a person: redirects to Stripe to hold the budget on a card",
        "parameters": [
          {
            "name": "booking_token",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^lwb_[A-Za-z0-9_-]{32}$"
            }
          }
        ],
        "responses": {
          "303": {
            "description": "To Stripe Checkout, or back to the booking page"
          }
        }
      }
    },
    "/bookings/{booking_token}/campaign-link": {
      "post": {
        "operationId": "addCampaignLink",
        "summary": "Add the app's App Store Connect campaign link (only its pt= is used)",
        "parameters": [
          {
            "name": "booking_token",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^lwb_[A-Za-z0-9_-]{32}$"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "link"
                ],
                "properties": {
                  "link": {
                    "type": "string",
                    "example": "https://apps.apple.com/app/apple-store/id123456789?pt=123456&ct=legwork&mt=8"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Booking"
                }
              }
            }
          },
          "400": {
            "description": "A problem, with a message to show.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "A problem, with a message to show.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "A problem, with a message to show.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/bookings/{booking_token}/counts": {
      "post": {
        "operationId": "reportCounts",
        "summary": "Report each marketer's first-time downloads; charges downloads x price from the hold and lets the rest go",
        "parameters": [
          {
            "name": "booking_token",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^lwb_[A-Za-z0-9_-]{32}$"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "counts"
                ],
                "properties": {
                  "counts": {
                    "type": "object",
                    "additionalProperties": {
                      "type": "integer",
                      "minimum": 0
                    },
                    "example": {
                      "lw7k3qx-steven": 12
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Booking"
                }
              }
            }
          },
          "400": {
            "description": "A problem, with a message to show.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "A problem, with a message to show.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/bookings/{booking_token}/rate": {
      "post": {
        "operationId": "rateMarketer",
        "summary": "Rate a marketer 1 to 5 once counts are in",
        "parameters": [
          {
            "name": "booking_token",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^lwb_[A-Za-z0-9_-]{32}$"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "campaign",
                  "stars"
                ],
                "properties": {
                  "campaign": {
                    "type": "string"
                  },
                  "stars": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 5
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Booking"
                }
              }
            }
          },
          "409": {
            "description": "A problem, with a message to show.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/bookings/{booking_token}/cancel": {
      "post": {
        "operationId": "cancelBooking",
        "summary": "Cancel before any spot is taken; the hold is let go",
        "parameters": [
          {
            "name": "booking_token",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^lwb_[A-Za-z0-9_-]{32}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Booking"
                }
              }
            }
          },
          "409": {
            "description": "A problem, with a message to show.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/board": {
      "get": {
        "operationId": "openBookings",
        "summary": "Open bookings, highest price per download first",
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/marketers": {
      "post": {
        "operationId": "joinAsMarketer",
        "summary": "Join as a marketer (18 or over, in London)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "email"
                ],
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "email": {
                    "type": "string"
                  },
                  "area": {
                    "type": "string"
                  },
                  "alerts": {
                    "type": "boolean",
                    "default": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Joined. Keep marketer_token."
          },
          "409": {
            "description": "A problem, with a message to show.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/marketers/link": {
      "post": {
        "operationId": "resendMarketerLink",
        "summary": "Email a fresh link to a marketer's page",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Always ok"
          }
        }
      }
    },
    "/me": {
      "get": {
        "operationId": "mySpots",
        "summary": "A marketer's spots, links, counts and money",
        "security": [
          {
            "marketer": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "A problem, with a message to show.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "setAlerts",
        "summary": "Turn new-booking emails on or off",
        "security": [
          {
            "marketer": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "alerts": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/spots": {
      "post": {
        "operationId": "takeSpot",
        "summary": "Take a spot on an open booking",
        "security": [
          {
            "marketer": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "ref"
                ],
                "properties": {
                  "ref": {
                    "type": "string",
                    "example": "LW-7K3QX"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Taken; the answer's taken.link is the marketer's own App Store link once the startup has added its campaign link"
          },
          "409": {
            "description": "A problem, with a message to show.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/spots/{ref}": {
      "delete": {
        "operationId": "giveBackSpot",
        "summary": "Give a spot back before the booking ends",
        "security": [
          {
            "marketer": []
          }
        ],
        "parameters": [
          {
            "name": "ref",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "409": {
            "description": "A problem, with a message to show.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "marketer": {
        "type": "http",
        "scheme": "bearer",
        "description": "The marketer_token from joining (lwm_...)"
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string"
          },
          "message": {
            "type": "string"
          }
        }
      },
      "App": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "developer": {
            "type": "string"
          },
          "icon": {
            "type": "string"
          },
          "url": {
            "type": "string"
          },
          "price": {
            "type": "string"
          },
          "category": {
            "type": "string"
          }
        }
      },
      "NewBooking": {
        "type": "object",
        "required": [
          "app",
          "budget_gbp",
          "contact_name",
          "contact_email"
        ],
        "properties": {
          "app": {
            "type": "string",
            "description": "App Store link (best), numeric id, or exact name"
          },
          "budget_gbp": {
            "type": "number",
            "minimum": 50,
            "maximum": 10000
          },
          "price_per_download_gbp": {
            "type": "number",
            "minimum": 3.5,
            "maximum": 50,
            "default": 4
          },
          "area": {
            "type": "string",
            "default": "Anywhere in London"
          },
          "when": {
            "type": "string",
            "enum": [
              "any time",
              "weekday lunchtimes",
              "evenings",
              "weekends"
            ],
            "default": "any time"
          },
          "brief": {
            "type": "string",
            "maxLength": 1000
          },
          "contact_name": {
            "type": "string"
          },
          "contact_email": {
            "type": "string"
          },
          "campaign_link": {
            "type": "string",
            "description": "Optional, can be added later"
          }
        }
      },
      "Booking": {
        "type": "object",
        "description": "A booking as it stands.",
        "properties": {
          "ref": {
            "type": "string",
            "example": "LW-7K3QX"
          },
          "status": {
            "type": "string",
            "enum": [
              "awaiting_hold",
              "open",
              "full",
              "charged",
              "released",
              "cancelled"
            ]
          },
          "status_text": {
            "type": "string"
          },
          "app": {
            "type": "object",
            "properties": {
              "id": {
                "type": "integer"
              },
              "name": {
                "type": "string"
              },
              "developer": {
                "type": "string"
              },
              "icon": {
                "type": "string"
              },
              "url": {
                "type": "string"
              }
            }
          },
          "price_per_download_gbp": {
            "type": "number"
          },
          "budget_gbp": {
            "type": "number"
          },
          "downloads_it_buys": {
            "type": "integer"
          },
          "spots": {
            "type": "integer"
          },
          "spots_taken": {
            "type": "integer"
          },
          "area": {
            "type": "string"
          },
          "when": {
            "type": "string"
          },
          "brief": {
            "type": [
              "string",
              "null"
            ]
          },
          "campaign_link_set": {
            "type": "boolean"
          },
          "hold": {
            "type": "object",
            "properties": {
              "state": {
                "type": "string",
                "enum": [
                  "none",
                  "held",
                  "taken",
                  "let go"
                ]
              },
              "held_gbp": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "until": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time"
              }
            }
          },
          "marketers": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "name": {
                  "type": "string"
                },
                "campaign": {
                  "type": "string"
                },
                "link": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "downloads": {
                  "type": [
                    "integer",
                    "null"
                  ]
                },
                "rating": {
                  "type": [
                    "integer",
                    "null"
                  ]
                }
              }
            }
          },
          "result": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "downloads": {
                "type": "integer"
              },
              "charged_gbp": {
                "type": "number"
              },
              "released_gbp": {
                "type": "number"
              },
              "charged_ahead": {
                "type": "boolean"
              }
            }
          },
          "next": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "What to do next, in words."
          },
          "payments": {
            "type": "string",
            "enum": [
              "test",
              "live",
              "off"
            ]
          },
          "urls": {
            "type": "object",
            "properties": {
              "manage": {
                "type": "string"
              },
              "pay": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          }
        }
      }
    }
  }
}
