{
  "openapi": "3.1.0",
  "info": {
    "title": "gronka api",
    "version": "1.0.0-preview",
    "description": "paste a link, get the file. preview: not live yet and may change."
  },
  "servers": [
    {
      "url": "https://api.gronka.dev"
    }
  ],
  "tags": [
    {
      "name": "download"
    },
    {
      "name": "content",
      "description": "the text behind a link: posts, threads and comments as json."
    },
    {
      "name": "keys",
      "description": "no accounts: a key is shown once and only its keyed hash is stored."
    }
  ],
  "paths": {
    "/v1/health": {
      "get": {
        "summary": "check the service",
        "responses": {
          "200": {
            "description": "service is up",
            "content": {
              "application/json": {
                "example": {
                  "ok": true
                }
              }
            }
          }
        }
      }
    },
    "/v1/download": {
      "post": {
        "tags": ["download"],
        "summary": "download from a link",
        "security": [
          {
            "apiKey": []
          },
          {}
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DownloadRequest"
              },
              "examples": {
                "plain": {
                  "value": {
                    "url": "https://x.com/user/status/123"
                  }
                },
                "trimmedAudio": {
                  "value": {
                    "url": "https://youtu.be/abc",
                    "mode": "audio",
                    "start": "1:00",
                    "end": "1:30"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "download result. a download that runs past about 80 seconds sends leading spaces first, and its error, if any, is in the body with this status",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/DownloadResult"
                    },
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ]
                },
                "examples": {
                  "direct": {
                    "value": {
                      "lane": "direct",
                      "files": [
                        {
                          "url": "https://video.twimg.com/...",
                          "filename": null,
                          "type": "video"
                        }
                      ]
                    }
                  },
                  "merge": {
                    "value": {
                      "lane": "worker",
                      "merge": true,
                      "filename": "clip.mp4",
                      "files": [
                        {
                          "url": "https://dl.gronka.dev/f/...",
                          "filename": "clip.video.mp4",
                          "kind": "video",
                          "size": 30123456
                        },
                        {
                          "url": "https://dl.gronka.dev/f/...",
                          "filename": "clip.audio.m4a",
                          "kind": "audio",
                          "size": 1234567
                        }
                      ]
                    }
                  },
                  "failed": {
                    "value": {
                      "error": {
                        "code": "VALIDATION_ERROR",
                        "message": "video duration exceeds the maximum allowed (60 minutes)."
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "413": {
            "$ref": "#/components/responses/Error"
          },
          "429": {
            "$ref": "#/components/responses/Error"
          },
          "500": {
            "$ref": "#/components/responses/Error"
          },
          "502": {
            "$ref": "#/components/responses/Error"
          }
        },
        "description": "limits: 50 downloads per 10 minutes per ip, or per key for api keys. keyed requests are also limited to 30 key checks per 10 minutes per ip."
      }
    },
    "/v1/keys": {
      "post": {
        "tags": ["keys"],
        "summary": "create an api key",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Turnstile"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "key shown once. it cannot be recovered.",
            "content": {
              "application/json": {
                "example": {
                  "id": "gk_ab12cd34",
                  "key": "gk_ab12cd34_..."
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "429": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "delete": {
        "tags": ["keys"],
        "summary": "revoke the api key sent as the bearer token",
        "security": [
          {
            "apiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "key revoked"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/v1/content": {
      "post": {
        "tags": ["content"],
        "summary": "read a post, thread or comments from a link",
        "description": "reads reddit posts (with the comment tree) and x posts (with the quoted post and the author's thread above it). same quota as /v1/download, shared per caller. preview: the shape may still change.",
        "security": [
          {
            "apiKey": []
          },
          {}
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ContentRequest"
              },
              "examples": {
                "reddit": {
                  "value": {
                    "url": "https://www.reddit.com/r/pics/comments/abc123/title/",
                    "depth": 3,
                    "comments": 10
                  }
                },
                "x": {
                  "value": {
                    "url": "https://x.com/user/status/123"
                  }
                },
                "text": {
                  "value": {
                    "url": "https://x.com/user/status/123",
                    "format": "text"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "the content as json, or plain text when format is text",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Thread"
                },
                "example": {
                  "source": "twitter",
                  "url": "https://x.com/user/status/123",
                  "post": {
                    "id": "123",
                    "url": "https://x.com/user/status/123",
                    "author": {
                      "handle": "user",
                      "name": "User",
                      "url": "https://x.com/user"
                    },
                    "createdAt": "2026-09-30T12:00:00.000Z",
                    "title": null,
                    "text": "3/ and that is why the penguin wins.",
                    "media": [],
                    "links": [],
                    "quoted": {
                      "id": "100",
                      "url": "https://x.com/other/status/100",
                      "author": {
                        "handle": "other",
                        "name": "Other",
                        "url": "https://x.com/other"
                      },
                      "createdAt": "2026-09-29T08:00:00.000Z",
                      "title": null,
                      "text": "penguins cannot fly",
                      "media": [],
                      "links": [],
                      "quoted": null,
                      "parent": null,
                      "stats": {
                        "likes": 12,
                        "reposts": 1,
                        "replies": 3,
                        "score": null,
                        "views": 900
                      },
                      "flags": {
                        "nsfw": false,
                        "spoiler": false,
                        "edited": false
                      },
                      "extra": {}
                    },
                    "parent": {
                      "id": "122",
                      "url": "https://x.com/user/status/122",
                      "author": {
                        "handle": "user",
                        "name": null,
                        "url": "https://x.com/user"
                      }
                    },
                    "stats": {
                      "likes": 40,
                      "reposts": 4,
                      "replies": 2,
                      "score": null,
                      "views": 3100
                    },
                    "flags": {
                      "nsfw": false,
                      "spoiler": false,
                      "edited": false
                    },
                    "extra": {
                      "lang": "en"
                    }
                  },
                  "thread": [
                    {
                      "id": "121",
                      "text": "1/ a thread about penguins"
                    },
                    {
                      "id": "122",
                      "text": "2/ they are birds"
                    },
                    {
                      "id": "123",
                      "text": "3/ and that is why the penguin wins."
                    }
                  ],
                  "comments": [],
                  "truncated": false
                }
              },
              "text/plain": {
                "schema": {
                  "type": "string"
                },
                "example": "twitter: https://x.com/user/status/123\n\n@user (User) · 2026-09-30 12:00\n1/ a thread about penguins\n"
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "429": {
            "$ref": "#/components/responses/Error"
          },
          "502": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "api key format: gk_<id>_<secret>"
      }
    },
    "responses": {
      "Error": {
        "description": "error response",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        },
        "headers": {
          "Retry-After": {
            "description": "seconds to wait, on 429 RATE_LIMITED",
            "schema": {
              "type": "integer"
            }
          }
        }
      }
    },
    "schemas": {
      "Turnstile": {
        "type": "object",
        "properties": {
          "turnstile": {
            "type": "string",
            "description": "turnstile token for the web page"
          }
        }
      },
      "DownloadRequest": {
        "type": "object",
        "required": ["url"],
        "properties": {
          "url": {
            "type": "string",
            "description": "link or text containing a link"
          },
          "mode": {
            "type": "string",
            "enum": ["auto", "audio", "mute"],
            "default": "auto",
            "description": "auto: the file as posted. audio: an mp3. mute: the video without its sound."
          },
          "start": {
            "type": "string",
            "description": "trim start in seconds, m:ss, or h:mm:ss"
          },
          "end": {
            "type": "string",
            "description": "trim end in seconds, m:ss, or h:mm:ss"
          },
          "split": {
            "type": "boolean",
            "default": true,
            "description": "false: never answer with separate video and audio parts (merge), get one finished file instead"
          },
          "turnstile": {
            "type": "string",
            "description": "web page only. scripts use an api key"
          }
        }
      },
      "DownloadResult": {
        "type": "object",
        "required": ["lane", "files"],
        "properties": {
          "lane": {
            "type": "string",
            "enum": ["direct", "worker", "r2"],
            "description": "direct uses the source link. worker streams through dl.gronka.dev; links last one hour. r2 copies to cdn.gronka.dev and is deleted after one hour."
          },
          "merge": {
            "type": "boolean",
            "description": "true when separate video and audio files need merging"
          },
          "filename": {
            "type": "string",
            "description": "suggested name for the merged file"
          },
          "files": {
            "type": "array",
            "items": {
              "type": "object",
              "required": ["url"],
              "properties": {
                "url": {
                  "type": "string"
                },
                "filename": {
                  "type": ["string", "null"]
                },
                "type": {
                  "type": "string",
                  "description": "media type, such as video, image, gif, or audio"
                },
                "kind": {
                  "type": "string",
                  "enum": ["video", "audio", "file"],
                  "description": "worker lane only. file is a plain image or other file"
                },
                "size": {
                  "type": ["integer", "null"]
                }
              }
            }
          }
        }
      },
      "Error": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "string",
                "examples": [
                  "BAD_REQUEST",
                  "BAD_URL",
                  "CONTENT_FAILED",
                  "CONTENT_GONE",
                  "DOWNLOAD_FAILED",
                  "INTERNAL",
                  "NETWORK_ERROR",
                  "RATE_LIMITED",
                  "UNAUTHORIZED",
                  "UNSUPPORTED_SOURCE",
                  "VALIDATION_ERROR",
                  "VERIFICATION_FAILED"
                ]
              },
              "message": {
                "type": "string",
                "description": "safe to show to a person"
              },
              "retryAfter": {
                "type": "integer",
                "description": "on 429 RATE_LIMITED: seconds to wait before retrying, also sent as a Retry-After header"
              }
            }
          }
        }
      },
      "ContentRequest": {
        "type": "object",
        "required": ["url"],
        "properties": {
          "url": {
            "type": "string",
            "description": "link or text containing a link. reddit post or comment permalinks, /s/ share links, x.com, twitter.com and their embed mirrors"
          },
          "format": {
            "type": "string",
            "enum": ["json", "text"],
            "default": "json",
            "description": "text: a plain-text rendering of the same content, one block per post, comments indented"
          },
          "thread": {
            "type": "boolean",
            "default": true,
            "description": "x only. follow the author's own replies upward from the linked post to rebuild the thread"
          },
          "depth": {
            "type": "integer",
            "minimum": 0,
            "maximum": 10,
            "default": 10,
            "description": "reddit only. how many comment levels to include; 0 leaves out comments"
          },
          "comments": {
            "type": "integer",
            "minimum": 0,
            "maximum": 20,
            "default": 0,
            "description": "replies to include, at most 20. off unless set"
          },
          "transcript": {
            "oneOf": [
              {
                "type": "boolean"
              },
              {
                "type": "string",
                "pattern": "^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$"
              }
            ],
            "default": false,
            "description": "youtube only. adds post.extra.transcript: { language, generated, segments: [{ start, end, text }] }, or null when there are no captions. true picks the video's own language, a code like \"en\" picks that one"
          },
          "turnstile": {
            "type": "string",
            "description": "web page only. scripts use an api key"
          }
        }
      },
      "Post": {
        "type": "object",
        "required": ["id", "url", "author", "text", "media", "links", "stats"],
        "properties": {
          "id": {
            "type": ["string", "null"]
          },
          "url": {
            "type": ["string", "null"]
          },
          "author": {
            "type": "object",
            "properties": {
              "handle": {
                "type": ["string", "null"],
                "description": "the @name or u/name, without the prefix"
              },
              "name": {
                "type": ["string", "null"]
              },
              "url": {
                "type": ["string", "null"]
              }
            }
          },
          "createdAt": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "title": {
            "type": ["string", "null"],
            "description": "reddit post title"
          },
          "text": {
            "type": "string",
            "description": "the body as plain text; reddit markdown is left as written"
          },
          "media": {
            "type": "array",
            "items": {
              "type": "object",
              "required": ["type", "url"],
              "properties": {
                "type": {
                  "type": "string",
                  "enum": ["image", "video", "gif"]
                },
                "url": {
                  "type": "string"
                },
                "alt": {
                  "type": ["string", "null"]
                },
                "width": {
                  "type": ["integer", "null"]
                },
                "height": {
                  "type": ["integer", "null"]
                }
              }
            }
          },
          "links": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "outbound links in the text, a reddit link post's target included"
          },
          "quoted": {
            "description": "the quoted post or crosspost parent",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Post"
              },
              {
                "type": "null"
              }
            ]
          },
          "parent": {
            "type": ["object", "null"],
            "description": "what this item replies to",
            "properties": {
              "id": {
                "type": ["string", "null"]
              },
              "url": {
                "type": ["string", "null"]
              },
              "author": {
                "oneOf": [
                  {
                    "type": "object",
                    "properties": {
                      "handle": {
                        "type": ["string", "null"],
                        "description": "the @name or u/name, without the prefix"
                      },
                      "name": {
                        "type": ["string", "null"]
                      },
                      "url": {
                        "type": ["string", "null"]
                      }
                    }
                  },
                  {
                    "type": "null"
                  }
                ]
              }
            }
          },
          "stats": {
            "type": "object",
            "description": "each a number or null when the source does not say",
            "properties": {
              "likes": {
                "type": ["integer", "null"]
              },
              "reposts": {
                "type": ["integer", "null"]
              },
              "replies": {
                "type": ["integer", "null"]
              },
              "score": {
                "type": ["integer", "null"],
                "description": "reddit net votes"
              },
              "views": {
                "type": ["integer", "null"]
              }
            }
          },
          "flags": {
            "type": "object",
            "properties": {
              "nsfw": {
                "type": "boolean"
              },
              "spoiler": {
                "type": "boolean"
              },
              "edited": {
                "type": "boolean"
              }
            }
          },
          "extra": {
            "type": "object",
            "description": "source fields that do not fit above: subreddit, flair, poll, article, communityNote, op, distinguished"
          }
        }
      },
      "Comment": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Post"
          },
          {
            "type": "object",
            "properties": {
              "depth": {
                "type": "integer"
              },
              "replies": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/Comment"
                }
              }
            }
          }
        ]
      },
      "Thread": {
        "type": "object",
        "required": ["source", "url", "post", "thread", "comments", "truncated"],
        "properties": {
          "source": {
            "type": "string",
            "enum": [
              "reddit",
              "twitter",
              "bluesky",
              "instagram",
              "tiktok",
              "booru",
              "pinterest",
              "video"
            ]
          },
          "url": {
            "type": "string",
            "description": "the canonical link"
          },
          "post": {
            "$ref": "#/components/schemas/Post"
          },
          "thread": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Post"
            },
            "description": "the author's own thread in order, the post included; empty when the post stands alone"
          },
          "comments": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Comment"
            },
            "description": "replies by others as a tree (reddit); always empty for x"
          },
          "truncated": {
            "type": "boolean",
            "description": "true when depth, comments or thread caps dropped something"
          }
        }
      }
    }
  }
}
