How-to › Describe your API

How to turn a Postman collection into OpenAPI with a coding agent#

Convert a Postman v2.1 export for a baseline, give a coding agent the gaps and saved examples, and check for invented required fields and a lossless round trip.

Audience
DevRel
Level
beginner
Topic
Author or repair a spec with a coding agent
Verified

Your API exists as a Postman collection of six saved requests, five of them in two folders. It holds eight example responses, a {{baseUrl}} variable, an API key on the collection, and a bearer token on one folder. A partner asks for an OpenAPI document. The converter you run writes http://{{baseurl}} as the server, two http security schemes called noauth and apikey, and a bare type: object on every response.

What you get

You will end up with a converter baseline, a prompt that gives a coding agent the collection and its gaps, and two checks on what the agent writes. This is for you if the collection is your only description.

Short answer

Run postman-to-openapi on the v2.1 export for a baseline that keeps every request and example and loses the variable, the auth, and every schema. Then give a coding agent the collection, the baseline, and a rule list. The rules resolve {{baseUrl}} into servers, turn collection and folder auth into securitySchemes, build schemas from the saved examples, and mark a field required only when every example carries it. Check the result by round-tripping it through openapi-to-postman.

You will need

Node 22 or later and a Postman collection exported in the v2.1 format, with saved example responses on its requests. Verified 2026-10-21 against Node 22.22.0, postman-to-openapi 3.0.1, openapi-to-postmanv2 6.4.0, and yaml 2.9.1. Export as v2.1 even though the current Postman app has moved its own collection format to 3.0, because the converter reads only 2.1 and 2.0.1

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Apidog importA team that will keep working in a GUI and export OpenAPI from it, auth includedAnother product between you and the file, and an export whose shape the product decides, with its own extension fieldsThe document must be reviewed and diffed in git by the people who own the API
Claude CodeGaps a converter cannot fill: the variable, folder auth, schemas from examples, prose from namesA draft that can invent a field or mark every one required, so each rule it follows needs a check that it didThe collection saved no example responses, so every schema would be a guess
openapi-to-postmanThe check on the result: a document that round-trips to the same requests kept themIt rebuilds one saved response per status code, so a lost example, a wrong type, or a missing description passes itYou want the schemas validated, which is a job for a linter
postman-to-openapiThe mechanical baseline in one command, with folders as tags, requests as operations, and examples keptAn archived project writing OpenAPI 3.0, with a bare type: object on every response, both auth types mapped to schemes the specification lacks, and the server variable mangledThe collection was generated from a specification you still have

The converter is deterministic and fast. What it loses is most of what made the collection useful: the variable that names the server, the token the Payments folder carried, and the shape of every response. The agent fills those gaps from the same file and will, left to itself, treat one example as a schema. Apidog does the same job behind a GUI and hands you its export format. The round trip is how you find out whether any of the three kept the requests.

Run the converter for the mechanical baseline

postman-to-openapi reads the export, turns folders into tags, requests into operations, Postman path variables into path parameters, and saved responses into examples. convert.mjs runs it and then reports, from the YAML it wrote, what arrived and what did not.

openapi: 3.0.0
info:
  title: Billing API
  description: Invoices and payments for the billing service.
  version: 1.0.0
servers:
  - url: http://{{baseurl}}
components:
  securitySchemes:
    noauthAuth:
      type: http
      scheme: noauth
    apikeyAuth:
      type: http
      scheme: apikey
security:
  - apikeyAuth: []
node convert.mjs
baseline.yaml from collection.json, postman-to-openapi 3.0.1
  openapi version        3.0.0
  servers                http://{{baseurl}}
  securitySchemes        noauthAuth: type http, scheme noauth; apikeyAuth: type http, scheme apikey
  tags                   Invoices, Payments
  operations             6, from 6 requests
  operationIds           0 of 6
  path parameters        2 declared, 0 paths still carry a :variable
  response examples      7 of 8 media types, from 8 saved examples
  response schemas       0 of 8 media types have properties, items, or a $ref
  problem+json           0 of 2 saved problem+json responses keep that media type
  request body schemas   0 of 2 have properties
  folder auth            Payments bearer, document security [{"apikeyAuth":[]}]

Six requests became six operations and eight saved responses became examples, the part worth keeping. The rest is the work order. The server is the variable with its name in lower case, which Postman itself could no longer resolve. The API key and the health check’s noauth both became http schemes with made-up scheme values, and the Payments folder’s bearer token is gone. Every schema is type: object with no properties, two problem responses lost their application/problem+json media type, and no operation has an id or a description.

The project that wrote this is archived, and its engines field stops at Node 19. Pin 3.0.1 and npm installs it on Node 22 with a warning. Leave the version off and npm’s version picker prefers a release whose engines requirement is satisfied, which here meant 1.7.3, the last release with no engines field, and one that ignores saved examples.2

Hand the agent the collection, the baseline, and the rules

The prompt names both files, says what the converter lost, and turns each gap into a rule with a check at the end. Rule 5 is the one that matters most and the one an agent will not apply on its own.

Convert collection.json, a Postman collection v2.1 export, into an OpenAPI 3.1 document at
openapi.yaml. baseline.yaml is what postman-to-openapi produced from the same file; start from
it and fix what it lost.

Rules:

1. Resolve `{{baseUrl}}` from the collection's `variable` list into `servers`. Leave no `{{`
   anywhere in the result.
2. Turn the collection-level auth into `components.securitySchemes` and a document-level
   `security` requirement. Turn the folder-level auth on Payments into a second scheme applied
   to that folder's operations. A request with `noauth` gets `security: []`.
3. Keep the path parameters the baseline declared. Drop the `Content-Type` header parameters
   it invented, and give each operation a snake_case verb-noun operationId.
4. Every saved example response becomes a response with that status code, an `examples` entry
   named after the saved response, and its body kept verbatim. Do not invent, shorten, or
   tidy a value.
5. Derive each response schema from the saved examples and put it under
   `components.schemas`. A field is `required` only when it appears in every saved example of
   that shape, and only when there are at least two such examples. With one example, leave
   every field optional.
6. Write `summary` from the request name and `description` from the request and folder
   descriptions. Keep the media type each saved response was served with, so a problem body
   stays `application/problem+json`.
7. After each batch of edits, run `node required.mjs openapi.yaml` and
   `node roundtrip.mjs openapi.yaml`, and stop when both pass. Do not touch collection.json.

No model is called by anything on this page. openapi.yaml and openapi.greedy.yaml were written for it as recorded stand-ins for the agent’s second and first drafts, so every check below runs offline and prints the same thing every time. The second draft resolves the auth the way rule 2 asks:

components:
  securitySchemes:
    apiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: The collection-level auth, with the apiKey variable as its value.
    paymentsToken:
      type: http
      scheme: bearer
      description: The folder-level auth on Payments, with the paymentsToken variable as its value.

The two payment operations carry security: [{ paymentsToken: [] }], the health check carries security: [], and everything else inherits the document-level apiKey requirement, which is what the collection’s inherit-from-parent rule meant all along.

Mark a field required only when two examples agree

A saved example response is one sample, and an agent asked for a schema reads a sample as a contract: every field present becomes required. The list response here has three invoices across two saved examples, and one of them carries memo. required.mjs walks each example alongside its schema, counts the instances of each named schema that carry each field, and compares that with the required lists.

const refName = (schema) => schema?.$ref?.replace('#/components/schemas/', '')

/** Walk an example value alongside its schema and record every instance of a named schema. */
export function collect(doc, schema, value, instances) {
  if (schema == null || value == null) return
  const name = refName(schema)
  if (name) {
    instances.set(name, [...(instances.get(name) ?? []), value])
    return collect(doc, doc.components.schemas[name], value, instances)
  }
  // OpenAPI 3.1 writes a nullable array as type: [array, 'null'], so type may be a list.
  if ([].concat(schema.type).includes('array') && Array.isArray(value)) {
    for (const item of value) collect(doc, schema.items, item, instances)
  } else if (schema.properties && typeof value === 'object') {
    for (const [key, sub] of Object.entries(schema.properties)) collect(doc, sub, value[key], instances)
  }
}
node required.mjs openapi.yaml
openapi.yaml: required lists against the saved examples
  InvoicePage  2 instances
    in all:   data, next_cursor
    required: data, next_cursor  ok
  Invoice      5 instances
    in all:   id, status, customer_id, total, currency, due_at
    in some:  memo (3 of 5), paid_at (1 of 5)
    required: id, status, customer_id, total, currency, due_at  ok
  Problem      2 instances
    in all:   type, title, status
    in some:  errors (1 of 2), detail (1 of 2)
    required: type, title, status  ok
  Payment      2 instances
    in all:   id, invoice_id, amount, currency, method, recorded_at
    in some:  reference (1 of 2)
    required: id, invoice_id, amount, currency, method, recorded_at  ok
  PaymentPage  1 instance, so nothing may be required
    in all:   data, next_cursor
    required: (none)  ok

Invoice appears five times across the saved responses: three list items, one created invoice, and one fetched one. Six fields are in all five and two are not, so memo and paid_at are optional and the rest are required. PaymentPage appears once, so its required list is empty. One example is a fact about one response, not a rule about all of them.

Round-trip the result back to a collection

openapi-to-postman converts the other way, and its folderStrategy=Tags option rebuilds folders from tags. roundtrip.mjs converts the document and compares the result with the original collection: the request count, and the method and path of each request with its variables blanked. The converter writes one saved response per status code, so the script finds each saved body among the document’s examples instead.

/** METHOD and path of one request, with the base URL variable dropped and path variables blanked. */
export function signature(item) {
  const url = item.request.url
  const parts = typeof url === 'string' ? url.replace(/^\{\{baseUrl\}\}/, '').split('?')[0].split('/').filter(Boolean) : url.path
  const path = '/' + parts.map((p) => (p.startsWith(':') || p.startsWith('{') ? '{}' : p)).join('/')
  return `${item.request.method} ${path}`
}
node roundtrip.mjs openapi.yaml
collection.json -> openapi.yaml -> openapi-to-postman 6.4.0
  requests         6 in the collection, 6 in the round trip
    GET /health                    both
    GET /invoices                  both
    GET /invoices/{}               both
    GET /payments                  both
    POST /invoices                 both
    POST /invoices/{}/payments     both
  saved examples   8 in the collection, 8 kept verbatim in openapi.yaml
  variables        baseUrl=https://api.example.com/v1

Six requests out, six back, under the same methods and paths, all eight saved bodies among the examples, and a baseUrl variable that resolves. The script exits 1 on a request present on one side only or a saved body the document lost, so it can gate a pull request that edits the document.

Check it worked

Six tests pin the converter’s output, the agent document, the two checks, and the round trip.

node --test convert.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1960.718592

The third test reads every examples entry in openapi.yaml and compares it, parsed, with the body of the saved response it is named after. It then counts them, so an example with no saved response behind it fails the test. The first test regenerates the baseline from the collection and asserts it equals the committed copy, so a converter upgrade that changes its output fails here rather than on the page.

When it goes wrong

Every field is required. openapi.greedy.yaml stands in for the first draft an agent writes unprompted, with each schema’s required list equal to its property list.

node required.mjs openapi.greedy.yaml
openapi.greedy.yaml: required lists against the saved examples
  InvoicePage  2 instances
    in all:   data, next_cursor
    required: data, next_cursor  ok
  Invoice      5 instances
    in all:   id, status, customer_id, total, currency, due_at
    in some:  memo (3 of 5), paid_at (1 of 5)
    required: id, status, customer_id, total, currency, due_at, memo, paid_at  wrong: memo, paid_at not in every example
  Problem      2 instances
    in all:   type, title, status
    in some:  errors (1 of 2), detail (1 of 2)
    required: type, title, status, detail, errors  wrong: detail, errors not in every example
  Payment      2 instances
    in all:   id, invoice_id, amount, currency, method, recorded_at
    in some:  reference (1 of 2)
    required: id, invoice_id, amount, currency, method, reference, recorded_at  wrong: reference not in every example
  PaymentPage  1 instance, so nothing may be required
    in all:   data, next_cursor
    required: data, next_cursor  wrong: one example is not agreement

Four of five schemas are wrong, and a client that validates responses against this draft would reject the first page of invoices, whose second invoice has no memo. Hand the agent this output, not a general complaint: it names the seven fields to take out of four required lists.

The round trip passes a document you know is bad. Run it on the baseline and it reports six requests, all eight saved bodies, and baseUrl=http://{{baseurl}} without complaint, because it compares requests and example bodies and reads no schema. Pair it with required.mjs and a linter; the page on setting up a Spectral lint loop for an agent editing OpenAPI has the ruleset.

A problem response comes back as application/json. The converter reads the saved response’s Content-Type header into a response header and still files the body under application/json, so rule 6 exists to put application/problem+json back.3 Check the media types in the result against the saved headers, which the fifth test does by name.

When not to do this

Do not hand an agent a collection that still carries secrets. An export holds the current value of every variable and every auth field, and apiKey is empty here only because the sample was written that way. Blank the values before the file reaches a prompt, or they reach the document’s examples and your git history.

Do not convert a collection that was generated from a specification you still have. A specification derived from a derived collection carries the losses of both conversions.

Do not let the agent decide required from one example. The greedy draft is what that produces, and the two-example rule is cheap to state and cheap to check.

Do not publish the converter’s output as the document. It is OpenAPI 3.0 from an archived project, its two security schemes fail the specification, and its schemas say nothing. It earns its place as a baseline that keeps the requests and the examples, and nowhere else.

Do not reach for an agent when a GUI is where the team lives. Apidog imports the collection with its folders, examples, variables, and auth, and exports OpenAPI 3.1 with the auth in securitySchemes, at the price of its extension fields in the export.

Last verified

Verified 2026-10-21 against Node 22.22.0, postman-to-openapi 3.0.1, openapi-to-postmanv2 6.4.0, and yaml 2.9.1. Every output block is what the command preceding it printed, run in the page’s code directory after npm ci, which warns that 3.0.1 declares Node 14 to 19 and installs it anyway. No model was called: openapi.yaml and openapi.greedy.yaml are committed stand-ins for a coding agent’s second and first drafts, written by hand to the prompt on this page. Apidog was not run; what the page says about it is read from its documentation. The 1.7.3 result was observed on an unpinned install in the same session and is not captured on the page.

Footnotes

  1. Postman documents that “the current version of Postman uses schema 3.0.0” for its collections, and that Newman runs a collection exported to the 2.1.0 format and cannot run a 3.0 one. The converter on this page was archived before 3.0 existed and reads 2.1 and 2.0. So the format the app now keeps collections in is one that neither its own runner nor the converter reads, and 2.1 remains the export everyone asks for. ↩︎ Back to text

  2. The project’s README says it “is no longer used or maintained” and invites forks, while the package.json beside it declares "node": ">=14 <20". npm reads that field when it resolves an unpinned version and prefers a release whose engines admit the running Node, which is how a 2026 install of an unpinned package produced 1.7.3. The newest release is the one you have to ask for by number. ↩︎ Back to text

  3. application/problem+json is the media type RFC 9457 registers for its problem details object, with type, title, status, detail, and instance as the members. The saved 404 carries four of the five, and the 422 three plus an errors array, which the RFC permits as an extension member. The converter filed both under application/json regardless. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.