README
¶
postman-sync
A Go CLI for managing Postman workspaces:
openapi-sync— sync a Postman collection from an OpenAPI spec without overwriting team-specific customizations (auth, scripts, saved responses, etc.)export— export collections and environments to local JSON filesimport— import collections and environments from local JSON files back into Postman
Inspired by dmiska25/postman_sync.py, rewritten in Go with a YAML-driven configuration.
How openapi-sync works
- Loads an OpenAPI spec (JSON or YAML) from a file or directory; when a directory is given, all external
$refreferences are resolved and bundled into a single document - Optionally sanitizes enum values to reduce diff noise
- Imports the spec into Postman via the API (creates a temporary collection)
- Downloads the generated collection, then deletes the temporary one
- Transforms the collection: flattens single-request folders, sorts alphabetically, injects configured headers/auth/scripts, sets the base URL, and adds doc links
- Merges the transformed collection into an existing Postman collection of the same name, preserving auth, pre-request scripts, test scripts, saved responses, and (by default) header/param values and body edits from previous manual edits
- Endpoints removed from the spec are removed from the collection (spec is source of truth)
Prerequisites
- Go 1.22+
- A Postman API key
- A Postman workspace ID
Installation
go install github.com/akyrey/postman-sync@latest
Or build from source:
git clone https://github.com/akyrey/postman-sync.git
cd postman-sync
make build
Configuration
Copy the example config and fill in your values:
cp postman-sync.example.yaml postman-sync.yaml
Secrets can be set via environment variables instead of the config file:
export POSTMAN_API_KEY=your-api-key
export POSTMAN_WORKSPACE_ID=your-workspace-id
Config structure
The config file has a global section and three optional command-specific sections:
# Global (required)
postman_api_key: "" # or POSTMAN_API_KEY env var
workspace_id: "" # or POSTMAN_WORKSPACE_ID env var
# openapi-sync command settings
openapi:
path: "./openapi.json"
base_url: "{{baseUrl}}"
sanitize_enums: true
# ... auth, scripts, headers, folder_overrides, doc_links
# export command settings
export:
output_dir: "./postman-export"
collections: ["all"]
environments: ["all"]
pretty: true
# import command settings
import:
input_dir: "./postman-export"
collections:
names: ["all"]
strategy: "overwrite"
environments:
names: ["all"]
Global fields
| Field | Required | Description |
|---|---|---|
postman_api_key |
Yes | Postman API key (or POSTMAN_API_KEY env var) |
workspace_id |
Yes | Postman workspace ID (or POSTMAN_WORKSPACE_ID env var) |
openapi section
| Field | Default | Description |
|---|---|---|
openapi.path |
./openapi.json |
Path to the OpenAPI spec file (JSON or YAML) or a directory containing a multi-file spec |
openapi.root_file |
— | Root spec filename when openapi.path is a directory (auto-detected if omitted) |
openapi.base_url |
{{baseUrl}} |
Base URL for all requests (Postman variable recommended) |
openapi.sanitize_enums |
true |
Replace enum values with <enum> to reduce diff noise |
openapi.doc_links.base_url |
— | Base URL for documentation links (omit to disable) |
openapi.common_headers |
[] |
Headers injected into every request (value and disabled state re-applied on every sync) |
openapi.common_query_params |
[] |
Query params injected into every request (value and disabled state re-applied on every sync) |
openapi.import_options |
{enableOptionalParameters: false} |
Options forwarded to Postman's OpenAPI importer; optional params/headers are generated disabled by default |
openapi.state_dir |
.postman-sync |
Local sync state: per-collection baselines used for three-way merges, and backups |
openapi.backup.enabled |
true |
Back up the existing collection before updating it. --no-backup disables it for one run |
openapi.backup.keep |
10 |
Backups retained per collection (0 keeps all) |
openapi.preserve_request_data |
true |
Keep header/param values, enabled state and body edits of existing requests (see merge behavior). --no-preserve disables it for one run |
openapi.auth |
— | Collection-level authentication |
openapi.auth.propagation |
— | Set to "inherit" to clear auth on all folders/requests so they inherit from the collection |
openapi.scripts |
— | Collection-level pre-request and test scripts |
openapi.folder_overrides |
{} |
Per-folder (tag) auth and script overrides |
export section
| Field | Default | Description |
|---|---|---|
export.output_dir |
./postman-export |
Directory for exported files |
export.collections |
[] |
Names to export, or ["all"]. Omit to skip. |
export.environments |
[] |
Names to export, or ["all"]. Omit to skip. |
export.pretty |
true |
Pretty-print JSON (recommended for git diffs) |
import section
| Field | Default | Description |
|---|---|---|
import.input_dir |
./postman-export |
Directory to read files from |
import.collections.names |
— | Names to import, or ["all"] |
import.collections.strategy |
overwrite |
overwrite or merge (see below) |
import.environments.names |
— | Names to import, or ["all"] |
import.environments.strategy |
overwrite |
overwrite only (environments have no merge logic) |
Usage
# Sync an OpenAPI spec into Postman
./bin/postman-sync openapi-sync
./bin/postman-sync openapi-sync --openapi-path ./api.yaml # single file
./bin/postman-sync openapi-sync --openapi-path ./specs/ # directory (auto-detect root)
./bin/postman-sync openapi-sync --openapi-path ./specs/ --openapi-root-file main.yaml # explicit root
./bin/postman-sync openapi-sync --config /path/to/config.yaml
./bin/postman-sync openapi-sync --no-preserve # regenerate request data from the spec
./bin/postman-sync openapi-sync --dry-run # show what would change, write nothing
./bin/postman-sync openapi-sync --no-backup # skip the pre-update backup
# Export collections and environments to disk
./bin/postman-sync export
./bin/postman-sync export --collections all --environments all
./bin/postman-sync export --collections "My API,Other API" --output-dir ./backup
# Import files back into Postman
./bin/postman-sync import
./bin/postman-sync import --collections all --strategy overwrite
./bin/postman-sync import --collections all --strategy merge --environments all
./bin/postman-sync import --input-dir ./backup
# Print version
./bin/postman-sync --version
# Using env vars for secrets
POSTMAN_API_KEY=xxx POSTMAN_WORKSPACE_ID=yyy ./bin/postman-sync openapi-sync
All flags override the corresponding config file values for that run. The --config flag is global and applies to all commands.
Export file layout
postman-export/
collections/
My API.json # full Postman CollectionWrapper JSON
Other API.json
environments/
Production.json # full Postman EnvironmentWrapper JSON
Staging.json
Files are the complete Postman API envelope, so they round-trip cleanly through import. Pretty-printed JSON by default — friendly for git diffs.
openapi-sync merge behavior
When a collection with the same name already exists in the workspace:
| What | Source |
|---|---|
| Which endpoints exist | New spec (removed endpoints are dropped) |
| Endpoint order | Alphabetical (from transform step) |
| Request URL structure, method, description | New spec |
| Header / query param / path variable / form field descriptions | New spec (carries (Required) and type info) |
| Header, query param, form field values and enabled state | Edited in Postman: preserved. Untouched: follow the spec ¹ |
| Headers, query params, form fields added by hand | Preserved ² |
| Headers, query params, form fields deleted by hand | Stay deleted ² |
| Path variable values | Edited: preserved. Untouched: follow the spec |
| Raw JSON body | Merged field by field with the same rules ³ |
| Other body | Edited: preserved (unless the body mode changed in the spec). Untouched: follow the spec |
common_headers / common_query_params |
Config file (re-applied after the merge) |
| Auth (item-level and folder-level) | Preserved from existing collection (unless auth.propagation: inherit) |
| Pre-request and test scripts | Preserved from existing collection |
| Saved example responses | Preserved from existing collection |
| Collection-level auth | Config file (if set), otherwise preserved from existing |
| Collection-level scripts | Config file (if set), otherwise preserved from existing |
On first sync (no existing collection), config-defined auth/scripts are applied as defaults.
¹ Rows about request data apply when preserve_request_data is true (default). With false or --no-preserve, request data comes entirely from the spec.
Baselines (three-way merge)
After every successful sync, the collection generated from the spec is saved as a baseline in <state_dir>/baselines/<collection>.json (state_dir defaults to .postman-sync, relative to the working directory). The next sync compares three versions of each request: the baseline, the current Postman collection, and the newly generated one:
- a value or enabled state still equal to the baseline was never touched, so it follows the spec (e.g. a placeholder that changed from
<string>to<long>); - a value that differs from the baseline was edited in Postman and is kept;
- an entry in Postman but not in the baseline was added by hand and is kept; an entry in the baseline but no longer in Postman was deleted by hand and stays deleted.
Requests without a baseline (first sync, new machine, deleted state dir) fall back to a two-way merge: matched entries keep their Postman value and state, and an entry counts as hand-added when it has no description (Postman's importer always copies the spec description). Keep state_dir out of version control (add it to .gitignore) unless the whole team syncs from the same checkout.
² With a baseline. Without one, hand-added entries are detected by their missing description and hand-deleted entries are re-added.
³ An untouched body is replaced by the spec's body. If an edited raw body is not valid JSON (e.g. it contains unquoted {{variables}}), it is kept as is. When a field changes kind in the spec (e.g. string → object) the spec value wins. Array elements are kept; object elements gain/lose fields based on the spec's first element. Without a baseline, fields no longer in the spec are always dropped.
Dry run
--dry-run runs the whole pipeline but writes nothing: no collection update/creation and no baseline. Postman still converts the spec through a temporary collection, which is deleted as usual. The output lists every change the sync would make:
~ (collection)
auth: bearer → oauth2
+ Orders/Create order
~ Orders/List orders
url: "{{baseUrl}}/orders?page=1" → "{{baseUrl}}/orders"
query page: disabled false → true
header x-skp-locale: description changed
- Orders/Legacy export
Summary: 1 added, 1 removed, 2 modified.
+ added, - removed, ~ modified. Added or removed folders are reported once, with their request count. Combine with --no-preserve to preview a full reset.
Backups
Before updating an existing collection, openapi-sync saves it as fetched from Postman to:
<state_dir>/backups/<UTC timestamp>/collections/<collection name>.json
The layout is the same as export's, so restoring a backup is a regular import:
./bin/postman-sync import --input-dir .postman-sync/backups/20261009T150405Z \
--collections all --strategy overwrite
Only the openapi.backup.keep most recent backups of each collection are retained (0 keeps all); pruning only deletes that collection's backup files and the directories they leave empty. If the backup cannot be written the sync stops before changing anything; use --no-backup to skip it. Dry runs never write backups.
Multi-file OpenAPI specs
When your OpenAPI spec is split across multiple files using $ref references to local files, point openapi.path at the directory (or the root file) and the tool will bundle everything into a single document before sending it to Postman.
# postman-sync.yaml
openapi:
path: "./specs" # directory containing openapi.yaml and referenced files
# root_file: "main.yaml" # optional — only needed when auto-detection is ambiguous
Or via CLI flags:
./bin/postman-sync openapi-sync --openapi-path ./specs/
./bin/postman-sync openapi-sync --openapi-path ./specs/ --openapi-root-file main.yaml
Root file auto-detection (when path is a directory and root_file is not set):
- Looks for well-known names in order:
openapi.yaml,openapi.yml,openapi.json,swagger.yaml,swagger.yml,swagger.json - If none found, uses the single
.yaml/.yml/.jsonfile at the top level of the directory - Returns an error if zero or multiple files are found — use
root_fileto resolve ambiguity
Note: All $ref references must point to local files. Remote URL references (http://) are not resolved.
import merge strategy
When import.collections.strategy: merge, the import command fetches the existing collection from Postman and runs the same MergeItems logic used by openapi-sync:
- Auth, scripts, and saved responses are preserved from the existing Postman collection.
- Request definitions (URL, method, body, headers) come from the imported file.
- Endpoints not in the file are dropped.
Use overwrite to replace the entire collection without merging.
Auth propagation (openapi-sync)
By default, Postman inherits auth from the parent for any item that has no explicit auth. However, after merge cycles, individual folders and requests often accumulate their own auth objects — preventing them from picking up changes to the collection-level auth.
Setting openapi.auth.propagation: "inherit" makes the tool clear auth on every folder and leaf request so they all inherit from the collection. Exceptions:
- Items with
auth.type: "noauth"are left untouched. - Folders listed in
folder_overrideskeep their explicit auth (their children are still processed).
openapi:
auth:
type: "oauth2"
attributes:
- key: "accessToken"
value: "{{oauth2_access_token}}"
type: "string"
# ... other oauth2 attributes
propagation: "inherit"
Auth types
openapi.auth.type supports: apikey, basic, bearer, oauth1, oauth2, digest, ntlm, hawk, awsv4, edgegrid, noauth.
OAuth2 configuration
OAuth2 has two sections in Postman: the current token (sent with requests) and the new token (OAuth2 flow config). Both are set via attributes key/value pairs.
openapi:
auth:
type: "oauth2"
attributes:
# --- Current Token ---
- key: "accessToken"
value: "{{oauth2_access_token}}"
type: "string"
- key: "tokenType"
value: "Bearer"
type: "string"
- key: "addTokenTo"
value: "header"
type: "string"
- key: "headerPrefix"
value: "Bearer"
type: "string"
# --- New Token (OAuth2 flow) ---
- key: "grant_type"
value: "client_credentials"
type: "string"
- key: "accessTokenUrl"
value: "https://auth.example.com/oauth/token"
type: "string"
- key: "clientId"
value: "{{oauth2_client_id}}"
type: "string"
- key: "clientSecret"
value: "{{oauth2_client_secret}}"
type: "string"
- key: "scope"
value: "openid profile"
type: "string"
All attribute values must be strings (including booleans like
"false").
Project structure
postman-sync/
├── cmd/postman-sync/
│ ├── main.go # Cobra root command, global flags, shared helpers
│ ├── openapi_sync.go # openapi-sync subcommand + pipeline
│ ├── export.go # export subcommand + pipeline
│ ├── import_cmd.go # import subcommand + pipeline
│ └── fileutil.go # Shared file I/O helpers
├── internal/
│ ├── config/
│ │ └── config.go # Config structs, loading, per-command validation
│ ├── openapi/
│ │ └── loader.go # Load OpenAPI spec (JSON/YAML) + enum sanitization
│ └── postman/
│ ├── types.go # Postman Collection v2.1 + Environment types
│ ├── types_json.go # JSON (un)marshalling keeping unmodelled fields (extra.go)
│ ├── client.go # Postman API HTTP client (collections + environments)
│ ├── transform.go # Collection transformers
│ ├── merge.go # Name-based recursive merge
│ ├── merge_request.go # Preserving (two/three-way) merge of headers/params/body
│ ├── jsonmerge.go # Order-preserving raw JSON body merge
│ └── diff.go # Collection diff used by --dry-run
├── postman-sync.example.yaml
├── Makefile
├── .goreleaser.yaml
├── go.mod
└── go.sum
Testing
make test # run all tests
make test-race # with race detector
make test-cover # with coverage report
License
GNU General Public License