# 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.

Source: https://voxgig.com/howto/postman-collection-to-openapi-with-an-agent

- Audience: devrel
- Level: beginner
- Verified: 2026-10-21
- Published: 2026-10-17

## 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](https://learning.postman.com/docs/use/use-collections/collections-schemas/)
to 3.0, because the converter reads only 2.1 and 2.0.[^1]

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Apidog import](https://docs.apidog.com/import-from-postman-635043m0) | A team that will keep working in a GUI and export OpenAPI from it, auth included | Another product between you and the file, and an export whose shape the product decides, with its own extension fields | The document must be reviewed and diffed in git by the people who own the API |
| [Claude Code](https://code.claude.com/docs/en/overview) | Gaps a converter cannot fill: the variable, folder auth, schemas from examples, prose from names | A draft that can invent a field or mark every one required, so each rule it follows needs a check that it did | The collection saved no example responses, so every schema would be a guess |
| [`openapi-to-postman`](https://github.com/postmanlabs/openapi-to-postman) | The check on the result: a document that round-trips to the same requests kept them | It rebuilds one saved response per status code, so a lost example, a wrong type, or a missing description passes it | You want the schemas validated, which is a job for a linter |
| [`postman-to-openapi`](https://joolfe.github.io/postman-to-openapi/) | The mechanical baseline in one command, with folders as tags, requests as operations, and examples kept | An 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 mangled | The 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.

```yaml title="baseline.yaml"
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: []
```

```bash
node convert.mjs
```

```text output
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](https://github.com/joolfe/postman-to-openapi), 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](https://github.com/npm/npm-pick-manifest) 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.

```md title="prompt.md"
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:

```yaml title="openapi.yaml"
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.

```js title="required.mjs"
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)
  }
}
```

```bash
node required.mjs openapi.yaml
```

```text output
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`](https://github.com/postmanlabs/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.

```js title="roundtrip.mjs"
/** 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}`
}
```

```bash
node roundtrip.mjs openapi.yaml
```

```text output
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.

```bash
node --test convert.test.mjs
```

```text output
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.

```bash
node required.mjs openapi.greedy.yaml
```

```text output
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](/howto/spectral-lint-loop-for-a-coding-agent)
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](https://docs.apidog.com/export-data-635117m0)
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.

## Related how-tos

- [Fill missing schemas and examples with a coding agent](/howto/fill-missing-schemas-and-examples-with-an-agent)

- [Set up a Spectral lint loop for an agent editing OpenAPI](/howto/spectral-lint-loop-for-a-coding-agent)

- [Stop a coding agent inventing endpoints not in the spec](/howto/stop-a-coding-agent-inventing-endpoints)

- [Serve your OpenAPI examples as mock responses](/howto/serve-openapi-examples-as-mock-responses)

## 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.

[^1]: Postman documents that "the current version of Postman uses schema 3.0.0" for its
[collections](https://learning.postman.com/docs/use/use-collections/collections-schemas/), 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.

[^2]: The project's [README](https://github.com/joolfe/postman-to-openapi) 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.

[^3]: `application/problem+json` is the media type [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)
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.