>_ Analyst Engineering

Schemathesis API Fuzzing: Property-Based Tests From Your OpenAPI Contract

Written by Ahmed at Analyst Engineering, a Senior Technical Business Analyst with 10+ years in banking and payments delivery.

Cover for Schemathesis API fuzzing, showing a schemathesis run command against a payments OpenAPI contract with server error and schema conformance checks.

Key takeaways

  • Schemathesis generates thousands of requests from an OpenAPI contract and checks every response against built-in properties: no 5xx, status codes and response bodies match the contract, invalid input is rejected, valid input is accepted, and declared authentication is enforced.
  • Property-based API testing finds the cases nobody writes by hand: the unicode name that crashes a serializer, the amount one digit past the maximum, the currency the schema allows but the code does not support.
  • Every Schemathesis finding is either a code defect or a contract defect, and deciding which is the analyst's job: the question is always whether the contract or the behaviour is what the business intended.
  • Fuzzing a payment creation endpoint creates payments. Run it only against a disposable environment, cap the rate, and never point it at a shared SIT ledger or a third-party sandbox.
  • A Schemathesis baseline lets a team adopt fuzzing on an existing API without blocking every pull request: known failures are recorded, and CI fails only on new ones.

Schemathesis is an open-source tool that does property-based API testing from your OpenAPI contract: it generates valid and invalid requests from the schemas, sends them to a running API, and checks every response against rules that must always hold, such as no 5xx errors, documented status codes only, and response bodies that match the contract. One command, schemathesis run, finds the unicode crash, the boundary amount, and the missing validation that hand-written test cases miss. The analyst’s job is triage: deciding whether each finding is a code defect or a contract defect.

The first time I pointed a fuzzer at a payments API, it found a 500 within minutes: a creditor name with characters outside the Latin alphabet broke a field mapping on the way to the core system. Forty hand-written test cases had passed. None of them would ever have contained that string, because none of us would have thought to type it. This article is the method I now use, written for analysts who already write API test cases and want the cases they would never think of. It belongs to the advanced track of APIs for Analysts and to the QA analyst hub.

What is property-based API testing?

A hand-written test states one input and one expected output: POST /payments with amount 100.00 EUR returns 201. A property-based test states a rule that must hold for every input, then lets a generator try to break it. “The API never returns a 5xx.” “Every response matches the schema the contract declares for that status code.” “A request that violates the schema is rejected with a 4xx.”

The idea comes from Hypothesis, the Python property-based testing library that Schemathesis is built on, and it is the same idea covered at unit level in developer testing in the AI era. What changes at the API level is the source of the generator: the OpenAPI contract. Every type, pattern, minimum, maxLength, enum, and required in the schema becomes both a source of valid data and a target for invalid data.

What does Schemathesis check?

Schemathesis 4 (4.30.0 at the time of writing, October 2026) runs a set of built-in checks on every response. All of them are enabled by default except max_response_time.

CheckThe propertyWhat a failure usually means
not_a_server_errorNo 5xx, everCode defect: unhandled input
status_code_conformanceStatus code is documentedContract drift or missing error response
content_type_conformanceContent-Type is documentedContract drift
response_headers_conformanceHeaders match their schemasContract drift
response_schema_conformanceBody matches the response schemaContract drift or a real field defect
negative_data_rejectionInvalid input is rejectedMissing validation
positive_data_acceptanceValid input is acceptedSchema too loose, or code too strict
missing_required_headerMissing required header is rejectedHeader not enforced
unsupported_methodUndocumented methods return 405Routing exposes more than documented
allow_header_conformanceAllow header matches the methodsMinor contract issue
use_after_freeDeleted resources are goneState defect
ensure_resource_availabilityCreated resources can be read backAsync creation not documented
ignored_authDeclared auth is enforcedSecurity defect

That last row deserves its own sentence: ignored_auth tells you when an operation declares a security scheme but answers without valid credentials. Treat it as a security finding and route it as API security testing describes.

How do you run Schemathesis against an API?

Install it as a tool (Python 3.10 or later) or run it without installing through uvx:

uv tool install schemathesis       # or: pip install schemathesis
schemathesis --version

Then run it against the contract and a test environment. st is the short alias for schemathesis:

st run openapi.yaml \
  --url https://payments-fuzz.test.internal \
  --header "Authorization: Bearer $FUZZ_TOKEN" \
  --include-path /payments \
  --max-examples 200 \
  --report junit \
  --report-junit-path results/schemathesis.xml

The options worth knowing:

OptionUse
--urlBase URL, required when the schema is a file
-H, --headerAuth and other headers; tokens are masked in output by default
--checks, --exclude-checksChoose checks, for example --checks not_a_server_error,response_schema_conformance
--mode positive, negative, allValid data only, invalid data only, or both (the default is all)
--phases examples,coverage,fuzzing,statefulWhich phases run (all by default)
-n, --max-examplesCases per operation in fuzzing, sequences in stateful (default 100)
--include-path, --include-methodScope the run
--continue-on-failureKeep testing an operation after its first failure
--seedReproduce a run
--report junit and --report-junit-pathJUnit XML for CI

Schemathesis runs in four phases. Examples sends the examples documented in the contract. Coverage deterministically targets every constraint: boundary values, pattern-matching strings, and each enum value. Fuzzing generates random data within, and in negative mode outside, the schema. Stateful chains operations together. Every failure prints a minimal curl command to reproduce it, and st replay re-sends saved failing cases after a fix.

What does a payments contract look like to a fuzzer?

Here is the request body for a payment initiation endpoint, simplified from the shape most European payment APIs use:

CreatePayment:
  type: object
  required: [amount, creditorIban, endToEndId]
  properties:
    amount:
      type: object
      required: [value, currency]
      properties:
        value:
          type: string
          pattern: '^\d{1,15}\.\d{2}$'
        currency:
          type: string
          pattern: '^[A-Z]{3}$'
    creditorIban:
      type: string
      format: iban
      maxLength: 34
    creditorName:
      type: string
      maxLength: 70
    endToEndId:
      type: string
      maxLength: 35

The analyst reads that and thinks “amount, currency, IBAN, reference.” Schemathesis reads it and generates, among thousands of others:

  • value of "0.00", "999999999999999.99", and a sixteen-digit value one character past the pattern.
  • currency values such as "QZV": they match ^[A-Z]{3}$ but are not currencies your scheme supports.
  • creditorName of exactly 70 characters and 71 characters, plus, in the fuzzing phase, strings built from non-Latin and combining characters that a database column sized in bytes may not hold.
  • endToEndId of 35 characters, the ISO 20022 maximum, and 36.
  • Bodies with amount missing, with amount as a number instead of an object, and with an extra unknown field.

That is a negative test design session done by a machine in seconds. It is not smarter than you; it is more patient.

Teach it what an IBAN is. format: iban is not a standard JSON Schema format, so Schemathesis has no built-in generator for it, generated values are almost never valid IBANs, and most positive requests die at IBAN validation before reaching the interesting code. Register a strategy in a hooks file so positive cases carry valid IBANs:

# hooks.py
import schemathesis
from hypothesis import strategies as st

TEST_IBANS = [
    "DE89370400440532013000",
    "GB82WEST12345698765432",
    "FR1420041010050500013M02606",
]

schemathesis.openapi.format("iban", st.sampled_from(TEST_IBANS))
SCHEMATHESIS_HOOKS=hooks.py st run openapi.yaml --url https://payments-fuzz.test.internal

The same hooks file can hold a business-rule check, so the fuzzer asserts what the contract cannot express:

@schemathesis.check
def amount_is_echoed(ctx, response, case):
    if case.method == "POST" and response.status_code == 201:
        if not isinstance(case.body, dict) or "amount" not in case.body:
            return
        sent = case.body["amount"]
        got = response.json()["amount"]
        if got != sent:
            raise AssertionError(f"amount changed: sent {sent}, got {got}")

How does stateful testing work?

Single requests miss defects that only appear in a sequence: create a payment, read it, cancel it, read it again. Schemathesis chains operations through OpenAPI links, and it also infers links where they are not declared, including from Location headers it sees in earlier phases. Declaring them makes the chain explicit:

paths:
  /payments:
    post:
      operationId: createPayment
      responses:
        '201':
          links:
            GetPayment:
              operationId: getPayment
              parameters:
                paymentId: '$response.body#/paymentId'

In the stateful phase, ensure_resource_availability checks that a payment you just created can be read back. On a payments API that often fails for a legitimate reason: creation returns 202 Accepted and the payment appears a few hundred milliseconds later. That is not a code defect. It is an undocumented eventual-consistency behaviour, and the fix is in the contract: document the 202 and the polling expectation.

How should an analyst triage Schemathesis findings?

This is where the analyst earns their place. A fuzzer produces findings; it does not produce decisions. Every finding goes into one of these buckets:

FindingAskUsually becomes
Server error (5xx)Could any input justify a crash?Code defect, always. Raise with the curl reproducer
Undocumented status codeIs the code right and the contract silent?Contract fix: document the response
Response violates schemaWhich is intended, the field or the schema?Contract fix if the schema is stale; code defect if a consumer depends on the schema
Invalid data acceptedShould this input ever be accepted?Code defect: missing validation
Valid data rejectedIs the schema looser than the business rule?Contract fix: tighten with enum, minimum, or a pattern
Declared auth not enforcedIs this operation meant to be public?Security defect, high priority
Created resource not readableIs creation asynchronous by design?Contract fix: document 202 and polling

Two examples from the payment contract above. Schemathesis sends currency: "QZV" and gets 400: positive_data_acceptance fails, because the schema says any three uppercase letters are valid. The code is right; the contract is too loose. The fix is enum: [EUR] for a SEPA endpoint. Schemathesis sends currency: "eur" and gets 201: negative_data_rejection fails, because the pattern requires uppercase. If the business wants case-insensitive input, fix the contract; if not, fix the code. Either way somebody must decide, and the decision is a requirement, which is why it belongs to the analyst rather than to whoever happens to be reading the CI log.

Every contract fix is also an input to contract testing: once the schema says enum: [EUR], consumers can rely on it. The habit of reading the contract this closely is the one I describe in the first time I read an OpenAPI contract, and you can rehearse the whole loop on a realistic payments contract in the free Analyze a Payment API lab.

The method for turning findings like these into a test suite and a defect backlog that a delivery team will act on is the core of API Testing and QA Mastery for BAs, and if the contract side is where your gaps are, API Fundamentals for Analysts covers reading schemas from the ground up.

How do you run Schemathesis in CI?

Use the official action, or install the CLI on a self-hosted runner inside the network. The action takes the schema, base URL, and authorization as inputs:

name: api-fuzz
on:
  pull_request:
    paths: ["openapi.yaml", "src/**"]

permissions:
  contents: read
  pull-requests: write

jobs:
  schemathesis:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: schemathesis/action@v3
        with:
          schema: openapi.yaml
          base-url: ${{ vars.FUZZ_BASE_URL }}
          authorization: "Bearer ${{ secrets.FUZZ_TOKEN }}"
          max-examples: "100"
          args: "--report junit --report-junit-path results/schemathesis.xml"
      - uses: actions/upload-artifact@v7
        if: ${{ !cancelled() }}
        with:
          name: schemathesis-results
          path: results/

Exit codes are simple: 0 all checks passed, 1 at least one check failed, 2 Schemathesis could not run (a broken schema or configuration). Pin the version (the action’s version input) so a new release does not change your gate.

Adopt it with a baseline. Point Schemathesis at an existing API and it will find twenty things on day one. If that blocks every pull request, the team will remove it by Friday. Record the known failures once, commit the file, and CI fails only on new ones:

st run openapi.yaml --url "$FUZZ_BASE_URL" --baseline schemathesis-baseline.json
# schemathesis.toml
baseline = "schemathesis-baseline.json"
headers = { Authorization = "Bearer ${FUZZ_TOKEN}" }
rate-limit = "auto"

[generation]
max-examples = 100

Then work the baseline down like any defect backlog. Where the suite sits next to your Bruno or Newman collection is the pipeline design in running API tests in CI.

What are the limits of API fuzzing?

  • It creates data. Fuzzing POST /payments creates payments, possibly thousands. Run it only against a disposable environment with its own database, never against a shared SIT ledger, and never against a third-party sandbox you do not own. rate-limit = "auto" follows Retry-After on 429, but a rate limit is not permission.
  • It only knows the contract. Schemathesis cannot know that a refund must not exceed the original payment amount unless you write that check. Business rules remain your hand-written cases, or custom checks in hooks.py.
  • A bad contract gives bad tests. If the schema has no patterns, no maximums, and no enums, there is little to fuzz. Thin findings are often a contract-quality finding in themselves.
  • Idempotency and concurrency are out of scope. It will not prove that two identical requests with the same Idempotency-Key create one payment; that needs the deliberate test in idempotency testing.
  • Triage takes time. Budget for it, or the findings become noise.

Alternatives, briefly. Microsoft’s RESTler is an open-source stateful REST API fuzzer that infers producer-consumer dependencies from the OpenAPI definition, aimed more at security and reliability research. Dredd, the older OpenAPI conformance tester, has an archived GitHub repository with no release since 2021, so I would not start a new suite on it.

The takeaway

Schemathesis turns an OpenAPI contract into thousands of property-based tests: no 5xx, documented codes and schemas only, invalid input rejected, valid input accepted, auth enforced. Give it valid domain data such as IBANs through a hooks file, add custom checks for the business rules the contract cannot express, run it against a disposable environment, and adopt it in CI with a baseline. Then do the part no tool does: decide, finding by finding, whether the code or the contract is wrong, because that decision is a requirement.

Ahmed is a Senior Technical Business Analyst with 10+ years in banking and payments. He builds practical guides and tools for analysts at The Tech BA Toolkit.

Tags: API Testing, Fuzzing, OpenAPI, Property-Based Testing, QA

About the author

Analyst Engineering is written by Ahmed, a Senior Technical Business Analyst with 10+ years of banking and payments delivery experience: ISO 20022 and SWIFT messaging, payments API integration, Kafka event validation, and production support. Every article comes from real delivery work, and each one is reviewed and updated as tools and standards change.

Go deeper on this

Not ready to buy? The free downloads are a no-cost place to start, and every article here stays free.

Free account

Practice on the Labs, keep your progress

A free account, no password: an email link signs you in. It saves your steps and self-assessments on the Labs, shows your missions on a dashboard, unlocks the solutions, and, if you tick the box, sends you new missions and articles when they ship.

Your email is used to sign you in. Nothing else, unless you ask. Privacy.