{
  "openapi": "3.1.0",
  "info": {
    "title": "badhttp",
    "version": "0.2.0",
    "summary": "The server that misbehaves on purpose.",
    "description": "A stateless catalogue of HTTP edge cases for testing clients, SDKs and agents, including x402 v2 paywalls that misbehave on purpose. No signup, no state. The only outbound requests are /402/pay's calls to an x402 facilitator. Every endpoint is deterministic unless documented otherwise.",
    "license": {
      "name": "MIT"
    }
  },
  "servers": [
    {
      "url": "https://badhttp.dev"
    }
  ],
  "paths": {
    "/status/{code}": {
      "get": {
        "summary": "Return the given status code (or a random one from a comma-separated list)",
        "parameters": [
          {
            "name": "code",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "examples": [
                "429",
                "200,500,503"
              ]
            },
            "description": "200–599, or a comma-separated list to pick from at random"
          },
          {
            "name": "retry-after",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "maximum": 86400
            },
            "description": "Adds a Retry-After header with this many seconds"
          }
        ],
        "responses": {
          "default": {
            "description": "The requested status; 204/205/304 have no body; 3xx carry Location: /redirect/0",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/delay/{seconds}": {
      "get": {
        "summary": "Wait before responding",
        "parameters": [
          {
            "name": "seconds",
            "in": "path",
            "required": true,
            "schema": {
              "type": "number",
              "minimum": 0,
              "maximum": 10
            },
            "description": "Seconds to wait, decimals allowed"
          }
        ],
        "responses": {
          "200": {
            "description": "{requested_ms, actual_ms}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/drip": {
      "get": {
        "summary": "Stream a chunked body slowly",
        "parameters": [
          {
            "name": "duration",
            "in": "query",
            "required": false,
            "schema": {
              "type": "number",
              "minimum": 0,
              "maximum": 20,
              "default": 5
            },
            "description": "Total seconds"
          },
          {
            "name": "chunks",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 10
            },
            "description": "Number of chunks"
          },
          {
            "name": "code",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 200,
              "maximum": 599,
              "default": 200
            },
            "description": "Status code to use (not 204, 205 or 304: those forbid a body)"
          }
        ],
        "responses": {
          "default": {
            "description": "text/plain, one line per chunk, chunked transfer encoding"
          }
        }
      }
    },
    "/truncate": {
      "get": {
        "summary": "Declare a Content-Length, then send fewer bytes and close",
        "parameters": [
          {
            "name": "length",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 1048576,
              "default": 1000
            },
            "description": "Declared Content-Length"
          },
          {
            "name": "send",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "maximum": 1048576
            },
            "description": "Bytes actually sent (default length/2)"
          }
        ],
        "responses": {
          "200": {
            "description": "application/octet-stream with Content-Length: length; only send bytes arrive, then the connection closes (HTTP/1.1) or the stream is reset (HTTP/2)"
          }
        }
      }
    },
    "/badjson/{flavor}": {
      "get": {
        "summary": "Serve a specific kind of broken or mislabeled JSON",
        "parameters": [
          {
            "name": "flavor",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "truncated",
                "trailing-comma",
                "single-quotes",
                "nan",
                "bom",
                "html",
                "mislabeled",
                "empty",
                "unterminated",
                "bigint",
                "duplicate-keys",
                "comments",
                "leading-garbage",
                "concatenated",
                "deep",
                "utf16"
              ]
            },
            "description": "Which failure; GET /badjson lists them"
          },
          {
            "name": "code",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 200,
              "maximum": 599,
              "default": 200
            },
            "description": "Status code to use (not 204, 205 or 304: those forbid a body)"
          }
        ],
        "responses": {
          "default": {
            "description": "Content-Type: application/json (except \"mislabeled\"); body is intentionally broken"
          }
        }
      }
    },
    "/flaky/{percent}": {
      "get": {
        "summary": "Fail a given percentage of the time",
        "parameters": [
          {
            "name": "percent",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "maximum": 100
            },
            "description": "Failure probability"
          },
          {
            "name": "fail",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 400,
              "maximum": 599,
              "default": 500
            },
            "description": "Status code on failure"
          },
          {
            "name": "seed",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "With seed, the outcome is a pure function of (seed, i)"
          },
          {
            "name": "i",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            },
            "description": "Attempt index; increment it per retry"
          }
        ],
        "responses": {
          "200": {
            "description": "{failed:false, roll, threshold, mode}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "default": {
            "description": "{failed:true, roll, threshold, mode, status, reason}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/redirect/{hops}": {
      "get": {
        "summary": "Redirect N times, then 200. Or /redirect/loop, which never ends.",
        "parameters": [
          {
            "name": "hops",
            "in": "path",
            "required": true,
            "schema": {
              "oneOf": [
                {
                  "type": "integer",
                  "minimum": 0,
                  "maximum": 10
                },
                {
                  "type": "string",
                  "enum": [
                    "loop"
                  ]
                }
              ]
            },
            "description": "Remaining hops, or \"loop\""
          },
          {
            "name": "code",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "enum": [
                301,
                302,
                303,
                307,
                308
              ],
              "default": 302
            },
            "description": "Redirect status"
          },
          {
            "name": "absolute",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean"
            },
            "description": "Use an absolute Location instead of a relative one"
          }
        ],
        "responses": {
          "200": {
            "description": "{redirects:\"done\"}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "3XX": {
            "description": "Location: /redirect/{hops-1}"
          }
        }
      }
    },
    "/headers": {
      "get": {
        "summary": "Echo the request headers as JSON",
        "responses": {
          "200": {
            "description": "{headers}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/echo": {
      "post": {
        "summary": "Echo method, path, query, headers and body (16 KB cap) as JSON",
        "responses": {
          "200": {
            "description": "{method, path, query, headers, body_bytes, body_truncated, body, json?}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      },
      "put": {
        "summary": "Echo method, path, query, headers and body (16 KB cap) as JSON",
        "responses": {
          "200": {
            "description": "{method, path, query, headers, body_bytes, body_truncated, body, json?}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      },
      "patch": {
        "summary": "Echo method, path, query, headers and body (16 KB cap) as JSON",
        "responses": {
          "200": {
            "description": "{method, path, query, headers, body_bytes, body_truncated, body, json?}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      },
      "delete": {
        "summary": "Echo method, path, query, headers and body (16 KB cap) as JSON",
        "responses": {
          "200": {
            "description": "{method, path, query, headers, body_bytes, body_truncated, body, json?}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      },
      "get": {
        "summary": "Always 405 with an Allow header, so clients can practice handling it",
        "responses": {
          "405": {
            "description": "{error}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      },
      "head": {
        "summary": "Always 405 with an Allow header, so clients can practice handling it",
        "responses": {
          "405": {
            "description": "{error}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/402": {
      "get": {
        "summary": "Index of the x402 payment scenarios: networks, price limits, pay-to address",
        "responses": {
          "200": {
            "description": "{scenarios, networks, pay_to, price}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/402/{scenario}": {
      "get": {
        "summary": "x402 v2 paywalls. /402/pay forwards a valid PAYMENT-SIGNATURE to a facilitator for verification and settlement; every other scenario misbehaves on purpose and never settles anything.",
        "description": "Without a PAYMENT-SIGNATURE header: 402 with the requirements base64-encoded in a PAYMENT-REQUIRED header (and repeated, readable, in the JSON body). With one: /402/pay verifies and settles through a facilitator (200 + PAYMENT-RESPONSE receipt on success); the others answer as their name suggests. GET /402 lists the facilitators and what has been exercised live so far.",
        "parameters": [
          {
            "name": "scenario",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "pay",
                "never",
                "reject",
                "slow",
                "crash",
                "bad-receipt",
                "overpriced",
                "wrong-network"
              ]
            },
            "description": "Which paywall"
          },
          {
            "name": "network",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "base-sepolia",
                "base"
              ],
              "default": "base-sepolia"
            },
            "description": "base-sepolia is test USDC (free); base is real USDC on Base mainnet"
          },
          {
            "name": "amount",
            "in": "query",
            "required": false,
            "schema": {
              "type": "number",
              "minimum": 0.001,
              "maximum": 1,
              "default": 0.01
            },
            "description": "Price in USD"
          },
          {
            "name": "reason",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "pattern": "^[a-z0-9_]+$",
              "maxLength": 64,
              "default": "insufficient_funds"
            },
            "description": "/402/reject only: the error to reject with (snake_case)"
          },
          {
            "name": "seconds",
            "in": "query",
            "required": false,
            "schema": {
              "type": "number",
              "minimum": 0,
              "maximum": 10,
              "default": 8
            },
            "description": "/402/slow only: how long to sit on a paid request before the 504"
          }
        ],
        "responses": {
          "200": {
            "description": "/402/pay after settlement: {paid:true, transaction, payer, network, …} plus a PAYMENT-RESPONSE header (base64 JSON of the facilitator settle response). /402/bad-receipt: a 200 whose PAYMENT-RESPONSE is garbage."
          },
          "202": {
            "description": "/402/pay when the facilitator broadcast the transfer but gave up waiting for confirmation (settlement_pending): {paid:\"pending\", transaction} plus PAYMENT-RESPONSE. Do not pay again."
          },
          "400": {
            "description": "Bad amount, network, reason or seconds (checked before the first 402, so a client never signs against a URL that will then fail)"
          },
          "402": {
            "description": "PAYMENT-REQUIRED header: base64 JSON {x402Version:2, resource, accepts:[{scheme:\"exact\", network:\"eip155:…\", asset, amount, payTo, maxTimeoutSeconds, extra:{name, version, assetTransferMethod}}], error?}"
          },
          "405": {
            "description": "Methods other than GET, HEAD and POST. HEAD receives the 402 and never settles."
          },
          "500": {
            "description": "/402/crash after payment"
          },
          "502": {
            "description": "/402/pay when no facilitator is reachable, or the settle call failed after verification (charged: \"unknown\")"
          },
          "504": {
            "description": "/402/slow after payment"
          }
        }
      }
    },
    "/402/broken/{flavor}": {
      "get": {
        "summary": "A malformed 402 of the chosen flavor; GET /402/broken lists them. Never settles.",
        "parameters": [
          {
            "name": "flavor",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "not-base64",
                "not-json",
                "no-accepts",
                "empty-accepts",
                "no-extra",
                "no-resource",
                "version-99",
                "decimal-amount",
                "missing-header",
                "v1-body"
              ]
            },
            "description": "Which malformation"
          },
          {
            "name": "network",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "base-sepolia",
                "base"
              ],
              "default": "base-sepolia"
            },
            "description": "Network named in the (broken) requirements"
          },
          {
            "name": "amount",
            "in": "query",
            "required": false,
            "schema": {
              "type": "number",
              "minimum": 0.001,
              "maximum": 1,
              "default": 0.01
            },
            "description": "Price named in the (broken) requirements"
          }
        ],
        "responses": {
          "400": {
            "description": "Bad amount or network"
          },
          "402": {
            "description": "Broken on purpose; see x-badhttp-flavor"
          }
        }
      }
    },
    "/books.json": {
      "get": {
        "summary": "Public books: every dollar spent and earned by this service",
        "responses": {
          "200": {
            "description": "{costs[], revenue[], totals, receive_address}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/health": {
      "get": {
        "summary": "Liveness",
        "responses": {
          "200": {
            "description": "{ok:true, version}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    }
  }
}
