{
  "openapi": "3.1.0",
  "info": {
    "title": "CrewPool public API",
    "version": "2026-11-wave3",
    "summary": "Read the CrewPool crew list (no contact details) and post a job.",
    "description": "CrewPool is a UK list of event, festival and bar crew. Crew join free. Companies post a job free and hire and pay crew directly; CrewPool takes nothing from crew pay. This document covers the public read endpoints and the job-post endpoint. Contact details are never returned by any endpoint listed here.\n\nPosting a job: the job is always saved, but crew are only emailed once the job is confirmed by email. A hirer whose address this client has confirmed before posts straight away; otherwise one confirmation email is sent first. A post from an address that has not been confirmed on this device returns `verify: true` and no `manage_url`; the hirer is emailed a confirmation link. Browser posts must carry a Cloudflare Turnstile token in `ts`. There is no open, token-free route for software agents: send people to https://joincrewpool.com/hire (optionally `?role=<skill key>&area=<town>`) to post.\n\nErrors are JSON of the form `{\"error\": \"<sentence>\"}`. Unknown paths under /api return 404 with `{\"error\":\"Not found\"}`; a known path called with the wrong method returns 405 with an `Allow` header.",
    "contact": {
      "name": "CrewPool",
      "email": "hello@joincrewpool.com",
      "url": "https://joincrewpool.com/"
    }
  },
  "servers": [
    {
      "url": "https://joincrewpool.com"
    }
  ],
  "tags": [
    {
      "name": "crew",
      "description": "Browse the list"
    },
    {
      "name": "jobs",
      "description": "Post and read a job"
    },
    {
      "name": "site",
      "description": "Pages and text files for people and assistants"
    }
  ],
  "paths": {
    "/api/search": {
      "get": {
        "tags": [
          "crew"
        ],
        "operationId": "searchCrew",
        "summary": "Search the crew list",
        "description": "Returns up to 200 active, consented crew, freshest-confirmed first. Names are shortened to \"First L.\", postcodes to the outward code, and phone, email and CV are always empty strings. `skill` matches work the person has done or says they would do.",
        "parameters": [
          {
            "name": "skill",
            "in": "query",
            "description": "Comma-separated skill keys (up to 6). Matches any.",
            "schema": {
              "type": "string",
              "examples": [
                "bell_tents,marquees"
              ]
            }
          },
          {
            "name": "traits",
            "in": "query",
            "description": "Comma-separated trait keys (licences, tickets, kit, overnight). Matches all.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "travel",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "local",
                "regional",
                "nationwide"
              ]
            }
          },
          {
            "name": "q",
            "in": "query",
            "description": "Free text matched against town, postcode and experience notes.",
            "schema": {
              "type": "string",
              "examples": [
                "leeds"
              ]
            }
          },
          {
            "name": "experienced",
            "in": "query",
            "description": "1 = only crew with written experience.",
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            }
          },
          {
            "name": "free",
            "in": "query",
            "description": "YYYY-MM-DD. Excludes crew who blocked that day.",
            "schema": {
              "type": "string",
              "format": "date"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Matching crew",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SearchResult"
                }
              }
            }
          },
          "500": {
            "description": "Search failed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/stats": {
      "get": {
        "tags": [
          "crew"
        ],
        "operationId": "poolStats",
        "summary": "Pool counts",
        "description": "Counts only. Returns `{\"show\": false}` while the pool is too small to publish. Cached for 5 minutes.",
        "responses": {
          "200": {
            "description": "Pool counts",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Stats"
                }
              }
            }
          }
        }
      }
    },
    "/api/config": {
      "get": {
        "tags": [
          "site"
        ],
        "operationId": "siteConfig",
        "summary": "Public flags",
        "description": "Whether text messages are switched on. At present they are off: job offers reach crew by email.",
        "responses": {
          "200": {
            "description": "Flags",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "sms"
                  ],
                  "properties": {
                    "sms": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/jobs": {
      "post": {
        "tags": [
          "jobs"
        ],
        "operationId": "postJob",
        "summary": "Post a job",
        "description": "Saves a brief. Crew are emailed only when the hirer's address is confirmed on this device (owner proof); otherwise the job is held, `verify` is true, and a confirmation email is sent (at most one every 10 minutes per address). Posting the same brief from the same address within 10 minutes returns the existing job with `deduped: true`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/JobPost"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Saved",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobPosted"
                }
              }
            }
          },
          "400": {
            "description": "Validation failed (bad email, no title, no valid skill, bad dates) or the email domain looks mistyped (`typo` / `did_you_mean`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Turnstile check failed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`target_worker_id` is no longer available.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Daily limit reached (20 posts per connection per day).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "The job could not be saved. Nothing was written.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/jobs/{id}": {
      "get": {
        "tags": [
          "jobs"
        ],
        "operationId": "getJob",
        "summary": "Read one job and who said yes",
        "description": "Needs the per-job manage token `k` that is emailed to the hirer after they confirm. The hirer's email address is not a credential.",
        "security": [
          {
            "manageToken": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "examples": [
                "job-abc123defg"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The job, counts and the crew who said yes (names shortened, no contact details)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobStatus"
                }
              }
            }
          },
          "403": {
            "description": "Missing, stale or wrong token (`stale: true`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Job not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/board": {
      "get": {
        "tags": [
          "site"
        ],
        "operationId": "jobsBoard",
        "summary": "Live jobs board (HTML)",
        "description": "Server-rendered list of open jobs with JobPosting structured data.",
        "responses": {
          "200": {
            "description": "HTML",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/crew-availability": {
      "get": {
        "tags": [
          "site"
        ],
        "operationId": "crewAvailability",
        "summary": "Crew counts by skill group and UK region (HTML)",
        "description": "Server-rendered table, refreshed hourly, cells under 5 suppressed.",
        "responses": {
          "200": {
            "description": "HTML",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/llms.txt": {
      "get": {
        "tags": [
          "site"
        ],
        "operationId": "llmsTxt",
        "summary": "Plain-language summary for assistants",
        "responses": {
          "200": {
            "description": "Plain text",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/llms-full.txt": {
      "get": {
        "tags": [
          "site"
        ],
        "operationId": "llmsFullTxt",
        "summary": "Long-form summary with the skill group by region table",
        "responses": {
          "200": {
            "description": "Plain text",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "manageToken": {
        "type": "apiKey",
        "in": "query",
        "name": "k",
        "description": "Per-job HMAC token from the hirer's confirmation email."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "A sentence a person can read."
          },
          "typo": {
            "type": "boolean",
            "description": "POST /api/jobs: the email domain looks mistyped. Resend with `email_ok: true` to override."
          },
          "did_you_mean": {
            "type": [
              "string",
              "null"
            ]
          },
          "stale": {
            "type": "boolean",
            "description": "GET /api/jobs/{id}: the token is out of date."
          },
          "allow": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "405 only."
          }
        }
      },
      "SkillKey": {
        "type": "string",
        "enum": [
          "bell_tents",
          "tipis_stretch",
          "marquees",
          "staging",
          "lighting",
          "power",
          "fencing",
          "furniture",
          "labour",
          "driving",
          "plant",
          "bar_staff",
          "cocktail",
          "barista",
          "personal_licence",
          "catering",
          "stewarding",
          "site_crew",
          "litter",
          "management",
          "site_manager",
          "safety",
          "market_stalls",
          "grotto_hosts",
          "performers",
          "waiting",
          "kitchen_porters",
          "chefs",
          "hosts"
        ],
        "description": "Role keys accepted by `skills_needed` and `skill`."
      },
      "SearchResult": {
        "type": "object",
        "required": [
          "ok",
          "count",
          "workers"
        ],
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "count": {
            "type": "integer",
            "maximum": 200
          },
          "balance": {
            "type": [
              "object",
              "null"
            ],
            "description": "Always null for anonymous callers."
          },
          "workers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CrewCard"
            }
          }
        }
      },
      "CrewCard": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "Public id. Not a credential."
          },
          "name": {
            "type": "string",
            "examples": [
              "Sam R."
            ]
          },
          "base_town": {
            "type": "string"
          },
          "base_postcode": {
            "type": "string",
            "description": "Outward code only, e.g. \"LS6\".",
            "examples": [
              "LS6"
            ]
          },
          "travel": {
            "type": "string",
            "enum": [
              "local",
              "regional",
              "nationwide"
            ]
          },
          "skills": {
            "type": "string",
            "description": "Comma-separated keys for work done before."
          },
          "willing": {
            "type": "string",
            "description": "Comma-separated keys the person would try."
          },
          "experience": {
            "type": [
              "string",
              "null"
            ]
          },
          "has_experience": {
            "type": "integer"
          },
          "verified": {
            "type": "integer",
            "description": "1 = a previous employer confirmed a reference. Not an ID or right-to-work check."
          },
          "created_at": {
            "type": "string"
          },
          "last_confirmed": {
            "type": "string",
            "description": "When availability was last confirmed."
          },
          "unavailable": {
            "type": [
              "string",
              "null"
            ],
            "description": "JSON array of YYYY-MM-DD dates the person cannot work."
          },
          "day_rate": {
            "type": [
              "string",
              "null"
            ]
          },
          "traits": {
            "type": "string"
          },
          "has_photo": {
            "type": "integer"
          },
          "age": {
            "type": [
              "integer",
              "null"
            ]
          },
          "phone": {
            "type": "string",
            "const": ""
          },
          "email": {
            "type": "string",
            "const": ""
          },
          "cv_url": {
            "type": "string",
            "const": ""
          },
          "locked": {
            "type": "boolean"
          }
        }
      },
      "Stats": {
        "type": "object",
        "required": [
          "show"
        ],
        "properties": {
          "show": {
            "type": "boolean"
          },
          "total": {
            "type": "integer",
            "description": "Active, consented crew."
          },
          "fresh": {
            "type": "integer",
            "description": "Confirmed (or signed up) in the last 30 days."
          },
          "experienced": {
            "type": "integer"
          },
          "joined_week": {
            "type": "integer"
          },
          "free_until_fills": {
            "type": "integer",
            "description": "Contacts stay free until this many outside jobs are filled."
          },
          "fills": {
            "type": "integer"
          },
          "hirer_jobs": {
            "type": "integer"
          },
          "hirers": {
            "type": "integer"
          },
          "last_fill": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "area": {
                "type": "string"
              },
              "label": {
                "type": "string"
              }
            }
          }
        }
      },
      "JobPost": {
        "type": "object",
        "required": [
          "email",
          "title",
          "skills_needed",
          "date_start"
        ],
        "properties": {
          "email": {
            "type": "string",
            "format": "email",
            "description": "Work email. Receives the confirmation link."
          },
          "title": {
            "type": "string",
            "maxLength": 120
          },
          "skills_needed": {
            "type": "string",
            "description": "Comma-separated SkillKey values. Unknown keys are dropped; at least one valid key is required.",
            "examples": [
              "bell_tents,labour"
            ]
          },
          "headcount": {
            "type": "integer",
            "minimum": 1,
            "maximum": 200,
            "default": 1
          },
          "date_start": {
            "type": "string",
            "format": "date"
          },
          "date_end": {
            "type": "string",
            "format": "date",
            "description": "Defaults to date_start."
          },
          "date_start2": {
            "type": "string",
            "format": "date",
            "description": "Optional pack-down window; must start after date_end."
          },
          "date_end2": {
            "type": "string",
            "format": "date"
          },
          "area": {
            "type": "string",
            "maxLength": 80,
            "description": "Town or postcode."
          },
          "travel": {
            "type": "string",
            "enum": [
              "local",
              "regional",
              "nationwide"
            ]
          },
          "company": {
            "type": "string",
            "maxLength": 120
          },
          "hours": {
            "type": "string",
            "maxLength": 60,
            "examples": [
              "8am-4pm"
            ]
          },
          "rate_note": {
            "type": "string",
            "maxLength": 60
          },
          "max_pay": {
            "type": "number",
            "exclusiveMinimum": 0
          },
          "max_pay_unit": {
            "type": "string",
            "enum": [
              "hour",
              "day"
            ]
          },
          "need_verified": {
            "type": "boolean",
            "description": "Only crew with a confirmed reference."
          },
          "local_only": {
            "type": "boolean",
            "description": "Prefer crew near the site."
          },
          "target_worker_id": {
            "type": "string",
            "description": "Ask one named person first (public id from /api/search). No fan-out."
          },
          "email_ok": {
            "type": "boolean",
            "description": "Override the mistyped-domain prompt."
          },
          "ts": {
            "type": "string",
            "description": "Cloudflare Turnstile token from the form. Required for browser posts."
          }
        }
      },
      "JobPosted": {
        "type": "object",
        "required": [
          "ok",
          "id"
        ],
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "id": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "skills": {
            "type": "string"
          },
          "headcount": {
            "type": "integer"
          },
          "manage_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Signed job page link. Null unless this device has proved it owns the address."
          },
          "targeted": {
            "type": "boolean"
          },
          "dispatch": {
            "type": [
              "object",
              "null"
            ],
            "description": "Crew invited, when the job was released. Null while held."
          },
          "verify": {
            "type": "boolean",
            "description": "True = the job is held. Nothing has been sent to crew; the hirer must click the confirmation email first."
          },
          "confirm_job": {
            "type": "boolean",
            "description": "True = the address was already confirmed but this device is not proven, so the email asks to confirm this job."
          },
          "confirm_recent": {
            "type": "boolean",
            "description": "True = a confirmation email went to this address in the last 10 minutes, so none was sent now."
          },
          "deduped": {
            "type": "boolean"
          },
          "link_emailed": {
            "type": "boolean"
          },
          "email": {
            "type": "string"
          },
          "post_secret": {
            "type": [
              "string",
              "null"
            ],
            "description": "Short-lived secret that lets the posting browser correct a mistyped address on a held job."
          }
        }
      },
      "JobStatus": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "job": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string"
              },
              "title": {
                "type": "string"
              },
              "skills_needed": {
                "type": "string"
              },
              "headcount": {
                "type": "integer"
              },
              "date_start": {
                "type": "string"
              },
              "date_end": {
                "type": "string"
              },
              "date_start2": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "date_end2": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "area": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "hours": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "status": {
                "type": "string",
                "enum": [
                  "open",
                  "filled",
                  "closed",
                  "expired"
                ]
              },
              "hirer_email": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          },
          "counts": {
            "type": "object",
            "properties": {
              "yes": {
                "type": "integer"
              },
              "no": {
                "type": "integer"
              },
              "pending": {
                "type": "integer"
              },
              "expired": {
                "type": "integer"
              },
              "target": {
                "type": "integer"
              }
            }
          },
          "buckets": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "bucket": {
                  "type": "string",
                  "examples": [
                    "5/5"
                  ]
                },
                "label": {
                  "type": "string"
                },
                "people": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "description": "Crew who said yes: shortened name, outward postcode, skills, score, picked, committed. No contact details."
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}
