{
  "openapi": "3.1.0",
  "info": {
    "title": "tilwhen date API",
    "version": "1.0.0",
    "description": "Free, read-only date calculations with auditable receipts. Every response includes a receipt: the normalized operation, pinned inputs, IANA timezone, counting convention (whole calendar days, today = day 0, exclusive; business days exclude the start day and include the end day), workweek, holiday jurisdiction with excluded holidays, engine and holiday-dataset versions, warnings, sources, and a stable result ID (deterministic hash of inputs + versions). Same inputs and versions always reproduce the same ID. Holiday jurisdictions are ISO 3166-1/-2 codes: US, GB, GB-SCT, GB-NIR, CA, AU, UG (legacy alias UK accepted). There is no default jurisdiction. Errors are always JSON: {\"error\":{\"code\",\"message\"}}. The API is rate limited per IP at 300 requests per 60 seconds and returns 429 with Retry-After when exceeded; no key, quota or signup is required below that.",
    "contact": {
      "url": "https://tilwhen.com/about"
    }
  },
  "servers": [
    {
      "url": "https://tilwhen.com"
    }
  ],
  "paths": {
    "/api/v1/parse": {
      "get": {
        "summary": "Parse a natural-language date query and answer it",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "maxLength": 200
            },
            "example": "90 business days from today in uganda"
          },
          {
            "name": "tz",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/Timezone"
            }
          },
          {
            "name": "locale",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "mdy",
                "dmy"
              ]
            },
            "description": "Resolves ambiguous slash dates like 03/04/2027; without it such dates return needsClarification instead of a guess."
          }
        ],
        "responses": {
          "200": {
            "description": "Parse outcome; parsed may be null with warnings or needsClarification",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ParseResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/add": {
      "get": {
        "summary": "Date N calendar days after a date",
        "parameters": [
          {
            "name": "date",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/DateOrToday"
            },
            "description": "Defaults to today (in tz)."
          },
          {
            "name": "days",
            "in": "query",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": -100000,
              "maximum": 100000
            }
          },
          {
            "name": "tz",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/Timezone"
            }
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/AnswerReceipt"
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/subtract": {
      "get": {
        "summary": "Date N calendar days before a date",
        "parameters": [
          {
            "name": "date",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/DateOrToday"
            }
          },
          {
            "name": "days",
            "in": "query",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": -100000,
              "maximum": 100000
            }
          },
          {
            "name": "tz",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/Timezone"
            }
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/AnswerReceipt"
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/diff": {
      "get": {
        "summary": "Signed whole days between two dates (exclusive)",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/DateOrToday"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/DateOrToday"
            }
          },
          {
            "name": "tz",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/Timezone"
            }
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/AnswerReceipt"
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/business-days": {
      "get": {
        "summary": "Business days between two dates, or the date N business days from a date",
        "description": "Span mode: from + to. Add mode: date (default today) + days (+ dir=from|before). Both modes accept cal (jurisdiction), custom (comma-separated ISO dates, mutually exclusive with cal), ww (workweek: mon-fri, sun-thu, mon-sat), tz.",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/DateOrToday"
            }
          },
          {
            "name": "to",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/DateOrToday"
            }
          },
          {
            "name": "date",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/DateOrToday"
            }
          },
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": -100000,
              "maximum": 100000
            }
          },
          {
            "name": "dir",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "from",
                "before"
              ]
            }
          },
          {
            "name": "cal",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/Jurisdiction"
            }
          },
          {
            "name": "custom",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Comma-separated ISO dates (max 1000): a custom organizational holiday calendar."
          },
          {
            "name": "ww",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/Workweek"
            }
          },
          {
            "name": "tz",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/Timezone"
            }
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/AnswerReceipt"
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/invoice-due": {
      "get": {
        "summary": "Invoice due date from net payment terms",
        "parameters": [
          {
            "name": "date",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/DateOrToday"
            },
            "description": "Invoice date; defaults to today."
          },
          {
            "name": "net",
            "in": "query",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "maximum": 3650
            }
          },
          {
            "name": "roll",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "1",
                "0",
                "true",
                "false"
              ]
            },
            "description": "Roll a weekend/holiday due date forward to the next business day. Any other value is a 400 rather than a silent \"no\"."
          },
          {
            "name": "cal",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/Jurisdiction"
            }
          },
          {
            "name": "custom",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "ww",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/Workweek"
            }
          },
          {
            "name": "tz",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/Timezone"
            }
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/AnswerReceipt"
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/next-occurrence": {
      "get": {
        "summary": "Next occurrence of a tracked event or weekday, strictly after the base date",
        "parameters": [
          {
            "name": "event",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Event slug (e.g. christmas). Exactly one of event/weekday."
          },
          {
            "name": "weekday",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "maximum": 6
            },
            "description": "0 = Sunday .. 6 = Saturday."
          },
          {
            "name": "from",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/DateOrToday"
            }
          },
          {
            "name": "tz",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/Timezone"
            }
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/AnswerReceipt"
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/holidays": {
      "get": {
        "summary": "Observed public holidays for a jurisdiction and year",
        "description": "Each entry carries the observed date, the nominal (legal) date, and status: rule (statutory) or declared (officially declared for that year). Declared-only holidays (e.g. Uganda's Eid days) are omitted for undeclared years and a holiday_data_incomplete warning is attached.",
        "parameters": [
          {
            "name": "cal",
            "in": "query",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/Jurisdiction"
            }
          },
          {
            "name": "year",
            "in": "query",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 1600,
              "maximum": 3000
            }
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/AnswerReceipt"
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/explain": {
      "get": {
        "summary": "Full receipt plus human-readable derivation steps for any operation",
        "parameters": [
          {
            "name": "op",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "add_days",
                "subtract_days",
                "days_between",
                "business_days",
                "invoice_due",
                "next_occurrence",
                "holiday_lookup"
              ]
            },
            "description": "Pass the same query parameters as that operation's endpoint."
          }
        ],
        "responses": {
          "200": {
            "description": "Receipt and steps",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "receipt": {
                      "$ref": "#/components/schemas/Receipt"
                    },
                    "steps": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/oembed": {
      "get": {
        "summary": "oEmbed provider for days-until pages",
        "parameters": [
          {
            "name": "url",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "A tilwhen.com/days-until/{slug} page URL."
          },
          {
            "name": "format",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "json"
              ]
            }
          },
          {
            "name": "maxwidth",
            "in": "query",
            "schema": {
              "type": "integer",
              "maximum": 600
            }
          },
          {
            "name": "maxheight",
            "in": "query",
            "schema": {
              "type": "integer",
              "maximum": 260
            }
          }
        ],
        "responses": {
          "200": {
            "description": "oEmbed rich response"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "501": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/badge/days-until/{target}.svg": {
      "get": {
        "summary": "Dynamic SVG countdown badge",
        "description": "target is a tracked event slug (e.g. christmas) or an ISO date. Optional label (max 40 chars, word characters and basic punctuation). Counted in UTC; cache expires at the next UTC midnight. Invalid input returns a deterministic 400 SVG.",
        "parameters": [
          {
            "name": "target",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "label",
            "in": "query",
            "schema": {
              "type": "string",
              "maxLength": 40
            }
          }
        ],
        "responses": {
          "200": {
            "description": "image/svg+xml badge"
          },
          "400": {
            "description": "image/svg+xml error badge"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "DateOrToday": {
        "type": "string",
        "description": "Strict ISO YYYY-MM-DD, or the literal \"today\" (resolved in tz)."
      },
      "Timezone": {
        "type": "string",
        "description": "IANA timezone name; defaults to UTC.",
        "example": "Africa/Kampala"
      },
      "Jurisdiction": {
        "type": "string",
        "enum": [
          "US",
          "GB",
          "GB-SCT",
          "GB-NIR",
          "CA",
          "AU",
          "UG",
          "UK"
        ],
        "description": "Holiday jurisdiction (ISO 3166-1/-2; UK is a legacy alias for GB)."
      },
      "Workweek": {
        "type": "string",
        "enum": [
          "mon-fri",
          "sun-thu",
          "mon-sat"
        ]
      },
      "Receipt": {
        "type": "object",
        "description": "Auditable calculation record; field order in serialized responses is stable.",
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^tw1-[0-9a-f]{16}$"
          },
          "query": {
            "type": "object"
          },
          "resolved": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          },
          "answer": {
            "type": "object"
          },
          "timezone": {
            "type": "string"
          },
          "convention": {
            "type": "string",
            "const": "exclusive-today-is-day-0"
          },
          "workweek": {
            "$ref": "#/components/schemas/Workweek"
          },
          "jurisdiction": {
            "type": [
              "string",
              "null"
            ]
          },
          "customHolidays": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "holidaysExcluded": {
            "type": "array",
            "description": "Itemised excluded holidays, capped at 500; see holidaysExcludedCount for the true total.",
            "items": {
              "type": "object",
              "properties": {
                "name": {
                  "type": "string"
                },
                "dateISO": {
                  "type": "string"
                }
              }
            }
          },
          "holidaysExcludedCount": {
            "type": "integer",
            "description": "Total holidays excluded by the calculation. Exceeds holidaysExcluded.length on very wide spans, where a holiday_list_truncated warning also appears."
          },
          "computedAt": {
            "type": "string",
            "format": "date-time"
          },
          "validFor": {
            "type": "string",
            "format": "date"
          },
          "engineVersion": {
            "type": "string"
          },
          "holidayDatasetVersion": {
            "type": "string"
          },
          "warnings": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "code": {
                  "type": "string"
                },
                "message": {
                  "type": "string"
                }
              }
            }
          },
          "sources": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "title": {
                  "type": "string"
                },
                "url": {
                  "type": "string"
                },
                "publisher": {
                  "type": "string"
                },
                "retrievedAt": {
                  "type": "string"
                }
              }
            }
          }
        }
      },
      "ParseResult": {
        "type": "object",
        "properties": {
          "parsed": {
            "type": [
              "object",
              "null"
            ]
          },
          "confidence": {
            "type": "number"
          },
          "warnings": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "needsClarification": {
            "type": "object"
          },
          "canonicalPath": {
            "type": [
              "string",
              "null"
            ]
          },
          "answer": {
            "type": "object"
          },
          "receipt": {
            "$ref": "#/components/schemas/Receipt"
          }
        }
      }
    },
    "responses": {
      "AnswerReceipt": {
        "description": "The answer plus its auditable receipt",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "answer": {
                  "type": "object"
                },
                "receipt": {
                  "$ref": "#/components/schemas/Receipt"
                }
              },
              "required": [
                "answer",
                "receipt"
              ]
            }
          }
        }
      },
      "Error": {
        "description": "Machine-readable error",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "error": {
                  "type": "object",
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "message": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "code",
                    "message"
                  ]
                }
              },
              "required": [
                "error"
              ]
            }
          }
        }
      },
      "RateLimited": {
        "description": "Rate limited. The API is free and unauthenticated, so requests are metered per IP at 300 per 60 seconds. Counters are per Cloudflare location and eventually consistent, so this is an abuse brake rather than an exact quota. Retry-After gives the window.",
        "headers": {
          "Retry-After": {
            "schema": {
              "type": "integer"
            },
            "description": "Seconds to wait before retrying."
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "error": {
                  "type": "object",
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "message": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "code",
                    "message"
                  ]
                }
              },
              "required": [
                "error"
              ]
            }
          }
        }
      }
    }
  }
}
