Migrate from Postman SDK Generator to Fern
Fern now generates your SDKs instead of Postman SDK Generator. The migration keeps what you already have:
- The same API spec — Your OpenAPI or Swagger file stays the single source of truth. You don’t need to convert or rewrite it.
- The same repositories — Each SDK keeps its GitHub repository.
- The same package names — npm, PyPI, Maven, NuGet, Packagist, and RubyGems coordinates and Go module paths carry over unchanged. The only exception is a Go module’s major version suffix (
/v2to/v3) when you ship a major release.
The one file that changes is the generator configuration. A Fern sdk-config.yml file replaces the Postman config.json file. A conversion prompt does this translation automatically and documents every setting it carries over.
Fern-generated code isn’t a drop-in replacement for the code Postman generated. Fern generates SDKs from the same spec and configuration, but expect differences in client setup, method and type names, error types, and package layout. Plan to release the Fern-generated SDKs as a new major version with an upgrade guide for your users. Step 6 shows how to find the differences before you release.
For details about every setting, see the Fern SDK config reference.
Step 1: Install the Fern CLI
Install the Fern CLI and confirm that it’s version 5.170.0 or newer:
For more information, see the Fern SDK quickstart.
Step 2: Create the Fern project
Run the following commands from the root of the repository that holds your API spec. Set --organization to your Fern organization name, and point --openapi at your existing spec file. Use a local file path: with a URL, fern init doesn’t create a local openapi.yml, which the conversion prompt needs.
The fern init command creates a fern/ folder with your Fern configuration, a copy of your spec, and a starter sdk-config.yml file:
Your original spec stays unchanged. fern init also creates docs.yml, which configures a Fern documentation site. SDK generation doesn’t need it, so the rm command deletes it. If you also want to generate docs with Fern, keep docs.yml and skip the rm command.
Run these commands in a repository that doesn’t already have a fern/ folder. If one exists, fern init restructures it into fern/apis/api1.
The starter sdk-config.yml is a placeholder. The conversion prompt in Step 3 replaces it with a configuration built from your config.json.
Step 3: Convert config.json to sdk-config.yml
Open your AI coding assistant, such as Claude Code, Cursor, or GitHub Copilot, at the repository root and paste in the conversion prompt. The assistant reads config.json and your spec, then writes two files:
fern/sdk-config.yml— Your new configuration.fern/MIGRATION_NOTES.md— Every setting inconfig.json, with where it went, why it wasn’t carried over, or what you need to decide.
The prompt follows these rules:
- It only carries over what you set — It never adds defaults.
- It never puts secrets in the file — It writes credentials as environment expressions, such as
${NPM_TOKEN}. - It doesn’t decide for you — It copies your version numbers exactly. It lists mismatches, such as the config’s API version compared with the spec’s
info.version, as decisions for you.
Step 4: Review the configuration
Read MIGRATION_NOTES.md first. Then check sdk-config.yml against the following mappings.
Settings that carry over
Settings handled outside sdk-config.yml
Decisions to make
Make the following decisions before you continue:
- SDK version — Set
sdkVersionto the next major version (for example,2.xto3.0.0). If your languages have different versions,sdkVersionalso appears on individual targets and overrides the root value, so update each target to its package’s next major version. For Go, add the matching/vNsuffix tomodulePathonly for v2 and later. A v1 release keeps the unsuffixed module path. - Delivery mode — Set
output.github.modeon each target. Fern recommendspull-request. - Spec adjustments — Keep any Fern-specific adjustments in an overrides file so your published spec stays unchanged.
Step 5: Validate the configuration
Run the following command and fix every error it reports before you continue:
Step 6: Preview the SDKs
Generate each SDK as a local download before anything reaches your repositories. To do this, do the following:
-
Copy
sdk-config.ymltosdk-config.preview.yml. -
In the copy, set each target’s
outputto{ delivery: zip }. -
Generate one language at a time:
-
Repeat for each language in
targets.
Compare each preview with your current release. Check the following:
- Client setup and authentication
- Method names and grouping
- Models and enums
- Errors and retries
- Pagination
- README and package metadata
The differences you find become your upgrade guide.
Step 7: Release the new major version
Run the following command:
Fern delivers each SDK to its repository using the delivery mode you chose, which is a pull request if you followed the recommendation. Merge the pull request and tag the release. Your existing repository release workflows then publish to the package registries.
To let Fern publish to the registries directly instead, add output.publish to a target. Pass credentials as environment expressions, such as ${NPM_TOKEN}. npm also supports trusted publishing.
Step 8: Retire Postman SDK Generator
After the release, do the following:
- Publish the upgrade guide with the new release.
- Mark the previous major version as deprecated in each registry and README.
- Turn off Postman SDK Generator for the API.
- Archive
config.jsonalongsideMIGRATION_NOTES.mdfor reference.
After the migration
- API changes — Update your spec, then run
fern generate. Running it from CI on every spec change keeps the SDKs current automatically. - Settings — Edit
sdk-config.yml. The Fern SDK config reference documents every field. Use per-target overrides when one language needs a different value. - Hand-written code — Enable
generation.customCodeand list the files inprotectedFilesso regeneration preserves them.
Conversion prompt
Use the copy button to copy the entire prompt, then paste it into your AI coding assistant as your message. Run it at the root of the repository that holds config.json and your API spec.
Convert config.json to sdk-config.yml
You are converting a Postman SDK generation config (`config.json`) into a Fern SDK Config v1 file (`sdk-config.yml`). Work only from the files in this repository. Do not invent values.
## Inputs
* `config.json`: the Postman SDK config. Most settings sit inside a top-level `"sdk": { … }` object. A few, such as `specFilePath`, `schemaVersion`, `workspace` and `entities`, sit beside it at the top level. Older files have no `sdk` wrapper. Read both levels. Inside, shared settings live under `customizations`, per-language settings under `languageOptions.<language>`, and repo settings under `publishing`. Field names in the mapping table below are written without these prefixes.
* The API spec referenced by `specFilePath` (OpenAPI or Swagger). Read it: it is the source of truth for servers, auth schemes and operations.
## Outputs
1. `fern/sdk-config.yml`. If `fern init` already created a starter file there, replace it completely and keep nothing from it.
2. `fern/MIGRATION_NOTES.md`: a table listing the path of **every leaf** in `config.json` with one status. Record paths and statuses, not values. Never write a credential or other sensitive value (token, password, secret, sensitive header value) in the notes or in your reply, even when `config.json` holds it in plain text; mark that leaf `needs decision: move it to an environment variable`. You may quote a non-sensitive value, such as a version number, where a decision needs it. A leaf is a scalar, an array element that is a scalar (e.g. `languages[2]`), or an empty `{}` / `[]`. Objects inside arrays are expanded to their scalar leaves. The statuses:
* `mapped → <sdk-config path>`
* `already in spec`: the value is redundant because the spec carries it
* `dropped: <reason>`
* `needs decision: <question>`
Use exactly one status per leaf. If a value is copied and also conflicts with something (e.g. `apiVersion` vs the spec’s `info.version`), mark it `mapped → …` and repeat it in “Decisions for the owner”. If an `api.auth` overlay is written (see Auth), the `auth[]` and `authentication.*` leaves it consumes are `mapped`.
End the notes with a short “Decisions for the owner” list.
3. Fern generates from the copy of the spec that `fern init` wrote to `fern/openapi.yml` (Step 2 of the migration guide). If `fern/openapi.yml` is missing, tell me to run Step 2. Do not move or copy files yourself.
## Hard rules for `sdk-config.yml`
1. Line 1 is `schemaVersion: sdk-config/v1`. Use YAML, 2-space indent.
2. **Strict schema.** Use only the keys listed in this prompt. Unknown keys fail validation. If a config value has no key here, put it in the notes as `dropped` or `needs decision`. Never invent a key.
3. **Sparse.** Emit only what `config.json` explicitly sets. Do not write defaults, and drop empty objects and arrays. If a value is explicitly set and happens to equal a default, still emit it: explicit intent must be preserved.
4. **No business decisions.** Copy `sdkVersion` and `apiVersion` exactly as they are, even if a major-version bump looks wise. Raise it in the notes instead.
5. **No secrets.** Any credential must be an environment reference such as `"${NPM_TOKEN}"`. Never copy a literal credential into a file or into your reply.
6. All file paths are relative to the `fern/` folder, with no `..` and no absolute paths.
7. Each language appears in `targets` at most once.
8. Placement: a value goes at the root only if **every** target would get the identical value. A value set for only some languages stays on those targets. A value that differs, or that is language-specific, goes on that target. Package identity, output, and language-specific generation settings always go on the target.
## Document shape (allowed keys)
Every key is optional unless the comments say otherwise, and the sparse rule still applies (e.g. emit only the `retry` keys the config sets). The allowed keys are this block **plus** the language-specific `generation` keys named in the mapping table, which go directly under that target’s `generation:`.
```yaml
schemaVersion: sdk-config/v1
sdkName: <string>
sdkVersion: <string> # root baseline; a target may override
apiVersion: <string>
source:
specs:
- { id: <slug>, type: openapi|swagger, path: openapi.yml } # the copy fern init made; type from the spec's top key (openapi: 3.x → openapi, swagger: "2.0" → swagger), not the filename; optional: overrides: [<path>]
api:
baseUrl: <url>
environments: [{ name, urls: [{ name, url }] }]
defaultEnvironment: <name>
environmentVariables: [{ name, description?, defaultValue? }]
headers: [{ name, value?, description?, environmentVariable? }]
auth:
schemes: # id must equal the spec's securitySchemes key when the scheme exists there; write these in Fern's shape, translated from the spec (see Auth)
- { id, type: api-key, location: header|query|cookie, name, prefix?, environmentVariable?, description? }
- { id, type: bearer, header?, prefix?, environmentVariable?, description? }
- { id, type: basic, username?: {environmentVariable?, description?}, password?: {...} }
- { id, type: oauth2, flows: [{ type: client-credentials, tokenUrl, scopes?: [{name, description?}] }],
clientId?: {environmentVariable?, description?}, clientSecret?: {...},
tokenHeader?, tokenPrefix?, refreshBufferSeconds?, description? }
- { id, type: custom, parameters: [{ location, name, prefix?, environmentVariable? }] }
client: # a target may also carry a sparse client: block
retry: { enabled, maxAttempts, retryDelayMs, maxDelayMs, jitterMs, backoffFactor, statusCodes: [int], methods: [string] }
responseHeaders: <bool>
responseValidation: <bool>
multiTenant: <bool>
timeoutMs: <number>
additionalConstructorParameters: [{ name, example?, description?, required? }]
tokenRefresh: { enabled, endpoint, accessTokenField?, refreshTokenField? }
docs:
snippets: { enabled: true, format?: md|json }
includeApiReference: <bool>
package: # root = shared metadata; target = identity
packageName, namespace, modulePath, groupId, artifactId, description, repository, homepage,
documentationUrl, license: { type, name?, url?, path? }, authors: [{name, email?}],
developers: [{name, email?, organization?, organizationUrl?}], keywords: [], classifiers: [],
projectUrls: [{label, url}]
generation: # root; a target may carry a sparse override plus its language keys
includeWatermark, includeOptionalSnippetParameters, buildAllModels, inferServiceNames,
includeDeprecatedOperations, multipleResponses, devContainer, allowMockClient,
ignoreFiles: [], reservedKeywords: [], customQueryPaths: [],
workflows: [{ path, outputName? }],
hooks: { enabled, source?: { type: path, location }, dependencies?: [{name, version, groupId?}] },
customCode: { enabled, source?: { type: path, location }, conflictStrategy?: mark|ours|theirs, trackChanges?, protectedFiles?: [] },
serialization: { enumRepresentation: enum|union|forward-compatible-enum }
targets:
- language: typescript|python|java|kotlin|go|csharp|php|ruby|rust|swift|cli|mcp|terraform
sdkName?, sdkVersion?, client?, docs?, package?, generation?
output:
delivery: github # or zip | files
github: { repository: <org>/<repo>, branch?, mode?: pull-request|push|release|commit|commit-and-release, privateRepository? }
publish?: # only if config.json configures registry publishing; credentials are flat on this object
registry: npm|pypi|maven|nuget|rubygems|go|composer|crates
token: "${NPM_TOKEN}" # npm, crates only
username: "${PYPI_USERNAME}", password: "${PYPI_PASSWORD}" # pypi, maven only
url?, releaseBranch?, tolerateRepublish?
```
## Field mapping
Resolution order for every value: `languageOptions.<lang>.<field>` → `customizations.<field>` → the top-level field.
| config.json | sdk-config.yml |
| ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `specFilePath` | `source.specs[0].path` = `openapi.yml` (the copy of this file that `fern init` made), `id` = lowercase slug of `sdkName` |
| `sdkName` | `sdkName`. `apiName` is only used when `sdkName` is missing |
| `apiVersion` | `apiVersion` |
| `languageOptions.*.sdkVersion` (or `sdkVersion`) | root `sdkVersion` if identical everywhere, else per target |
| `languages[]` | one `targets[]` entry each. Only `docs`-type entries are not languages |
| `auth[]` + `customizations.authentication` | `api.auth` for a scheme the spec doesn’t apply, and for settings the spec can’t express (see Auth below) |
| `authentication.apiKey.header` | api-key scheme `name` (location `header`) |
| `authentication.access.prefix` | bearer scheme `prefix` |
| `authentication.custom.prefix` | custom scheme, parameter `{location: header, name: Authorization, prefix}` |
| `authentication.oauth.refreshBuffer` (**milliseconds** in Postman configs, e.g. 5000 = 5 s) | oauth2 `refreshBufferSeconds` = value ÷ 1000 |
| `authentication.oauth.enabled: false` | omit the oauth2 scheme |
| `environmentVariables[{name, description}]` | if the names are clearly the auth credentials (e.g. CLIENT\_ID / CLIENT\_SECRET / API\_KEY / TOKEN), attach them to the matching auth scheme: `clientId/clientSecret: {environmentVariable: <name>, description}` for oauth2, `environmentVariable` for api-key/bearer. Otherwise `api.environmentVariables` |
| `baseUrl` | `api.baseUrl`, only if it differs from the spec’s `servers` |
| `environments[{name,url}]` | `api.environments[{name, urls:[{name, url}]}]` |
| `retry.enabled / maxAttempts` | `client.retry.enabled / maxAttempts` |
| `retry.retryDelay / maxDelay / retryDelayJitter` (ms) | `client.retry.retryDelayMs / maxDelayMs / jitterMs` |
| `retry.backOffFactor` | `client.retry.backoffFactor` |
| `retry.httpCodesToRetry / httpMethodsToRetry` | `client.retry.statusCodes / methods` |
| `responseHeaders`, `multiTenant` | `client.responseHeaders`, `client.multiTenant` |
| `languageOptions.<lang>.validateResponses` | target `client.responseValidation` |
| `languageOptions.<lang>.additionalConstructorParameters` / `additionalVariables` | target `client.additionalConstructorParameters` |
| `refreshToken {endpoint, bearerKey, refreshKey}` | `client.tokenRefresh {enabled: true, endpoint, accessTokenField, refreshTokenField}` |
| `devContainer`, `inferServiceNames`, `includeOptionalSnippetParameters`, `includeWatermark`, `buildAllModels`, `allowMockClient` | `generation.<same name>` |
| `deprecatedOperations.include` | `generation.includeDeprecatedOperations` |
| `multipleResponses.enabled` | `generation.multipleResponses` |
| `ignoreFiles` / `ignore`, `reservedKeywords` | `generation.ignoreFiles`, `generation.reservedKeywords` |
| `customQueries.paths` | `generation.customQueryPaths` |
| `workflow {path, outputName}` | `generation.workflows: [{path, outputName}]` |
| `hooks` / `hookDependencies` | `generation.hooks {enabled: true, source: {type: path, location: <relative dir>}, dependencies}`. Ask for the location if it is unknown |
| `customCode {conflictStrategy, trackChanges, protectedFiles}` | `generation.customCode {enabled: true, …}` (`previousBuildId` → dropped) |
| `languageOptions.<lang>.generateEnumAs: enum\|union` | target `generation.serialization.enumRepresentation` |
| `license {type, name?, url?, path?}` | `package.license` |
| `docs: ["snippets"]` / `documentation.snippets.format` | `docs.snippets {enabled: true, format?}` |
| `publishing.githubOrg` + `languageOptions.<lang>.githubRepoName` | target `output: {delivery: github, github: {repository: "<org>/<repo>"}}`. With no repo info at all, use `output: {delivery: zip}` and flag it |
| `githubRef` / `languageOptions.<lang>.targetBranch` / `publishing.releaseBranch` | `output.github.branch` |
| `privateRepo` | `output.github.privateRepository` |
| TS `npmName` / `npmOrg` | `package.packageName` / `package.namespace` |
| TS `httpClient` (`axios`\|`fetch`) | target `generation.httpClient: {name: <value>}` |
| TS `typescriptVersion`, `zodVersion`, `compilerOptions`, `bundle`, `exportClassDefault`, `namingStrategy` | target `generation.<same>` |
| TS `scripts {name: cmd}` | target `generation.scripts: [{name, command}]` (sorted by name) |
| Python `pypiPackageName` | `package.packageName` |
| Python `pythonVersion`, `pydanticVersion`, `pydantic`, `client`, `alwaysInitializeOptionals` | target `generation.<same>` |
| Java/Kotlin `groupId`, `artifactId` | `package.groupId`, `package.artifactId` |
| Java `includeKotlinSnippets`, `packageLayout`, `useLocalDateForDates`, `collapseOptionalNullable` | target `generation.<same>` |
| Go `goModuleName` | `package.modulePath` |
| Go `legacyComplexModels`, `unionVersion`, `includeLegacyClientOptions` | target `generation.<same>` |
| C# `packageId` | `package.packageName` |
| C# `useOptionalWrapper`, `simplifyObjectDictionaries`, `explicitNamespaces`, `rootNamespaceForCoreClasses`, `includeExceptionHandler` | target `generation.<same>` |
| PHP `packageName` (or `composerVendorName` + `composerPackageName` → `vendor/name`) | `package.packageName` |
| PHP `propertyAccess`, `generateClientInterfaces` | target `generation.<same>` |
| Ruby `gemName` | `package.packageName` |
| Ruby `requirePaths` | target `generation.requirePaths` |
| CLI `goModuleName` | `package.modulePath` |
| CLI `skills.enabled`, `paginationParameters` | target `generation.skills: <bool>` (a plain boolean, not an object), `generation.paginationParameters` |
| any language: `homepage`, `repository`, `authors`, `developers`, `classifiers`, `projectUrls {label: url}` | `package.<same>` (`projectUrls` → `[{label, url}]`). Root only if every target has the identical value (rule 8) |
**Always dropped** (record them in the notes): `schemaVersion` (the old one), `workspace`, `entities`, `liblabVersion`, `customCode.previousBuildId`, empty `injectedModels`.
**Not set in config.json → do not add, but note it:**
* no registry publishing configured (only `githubOrg`/repo names) → omit `publish`; say releases stay with the repos’ own CI
* Ruby without `gemName` → omit `packageName`; say it is required before publishing to RubyGems
* no GitHub `mode` → omit it; ask whether they want `pull-request` or `push`
**No equivalent yet** (mark `needs decision`): `generateEnv`, non-empty `injectedModels`, `endpointCustomizations`, `docs: ["enhancedApiSpec"]` and anything under `integrations` (e.g. Redocly), plus any key not listed above.
## Auth: the spec decides what is sent, config.json fills the gaps
A scheme in `components.securitySchemes` only declares it. It applies to requests when the root `security` or an operation’s `security` references it, and `security: []` on an operation turns it off. Check this effective security before you decide anything about a scheme in `auth[]`:
* The spec declares **and applies** the scheme: do not restate it just because `auth[]` lists it. Mark it `already in spec`.
* The spec declares the scheme but never applies it, or disables it for the operations that need it: mark it `needs decision`. Do not edit the spec.
* The spec doesn’t declare the scheme at all (authentication defined only by the legacy `auth` setting, for example `auth: ["basic"]`): write it in `api.auth` when `config.json` gives enough information (type, header or parameter name, token URL). Otherwise mark it `needs decision` and say it must be resolved before release, because the SDK would send no credentials.
* Add an `api.auth` scheme for a scheme the spec doesn’t apply, and also when `config.json` carries something the spec cannot express: a refresh buffer, credential environment-variable names, a token prefix, or a header name that differs from the spec.
When you write a scheme, translate the spec’s fields into the shape in the allowed-keys block. Do not copy them verbatim:
* `type: apiKey` with `in` and `name` becomes `type: api-key` with `location` and `name`.
* `type: http` with `scheme: bearer` becomes `type: bearer`, and with `scheme: basic` becomes `type: basic`.
* `type: oauth2` with `flows.clientCredentials: {tokenUrl, scopes: {<name>: <description>}}` becomes `flows: [{type: client-credentials, tokenUrl, scopes: [{name, description}]}]`. Omit empty `scopes`.
* Anything else (other OAuth flows, `openIdConnect`, `mutualTLS`, other `http` schemes): do not write a scheme. Mark it `needs decision`.
For a scheme the spec declares, reuse its securityScheme key as `id`, and say in the notes that the block intentionally overlays the spec. Mismatches between `config.json` and the spec (auth type, token URL, base URL, `apiVersion` vs `info.version`) go under `needs decision`. Never silently pick one.
## Spec checks (report in the notes; do not edit the spec)
* Missing or duplicate `operationId`s, and operations without tags. These drive method names.
* Vendor extensions: `x-codeSamples`, `x-tagGroups`, `x-logo` are docs-only and can be ignored for SDKs (`x-codeSamples` will go stale). `x-webhooks` defines webhook payloads: list the events and mark them `needs decision`, since webhook types may need verifying in the generated SDKs. The operationId and tag checks above apply to paths only, not to webhooks.
* Deprecated **operations** (only counts; deprecated properties need not be listed), if `includeDeprecatedOperations` is set.
## Validate
1. Re-read `sdk-config.yml` against the allowed-keys list above, key by key.
2. If the Fern CLI is installed, check `fern --version`. SDK Config support needs **5.141.0 or newer**; older versions ignore `sdk-config.yml`. On a new enough version, run `fern check` from the repo root and fix every SDK Config error it reports. If the CLI is missing or too old, say so; do not claim the file was validated.
3. Finish your reply with: any setup step I still need to do, then a 5-line summary: target count, how many `config.json` leaves are mapped / already in spec / dropped / need a decision, and the top 3 decisions. “Decisions for the owner” in the notes may be longer than 3.