obfus.link
Converters

curl to fetch: secret extraction, retry wrappers, annotated output

Translate any curl command to fetch, axios, node-fetch, got, or ky. Optional env-var extraction hoists credentials to process.env; optional retry wrapper produces production-ready code with exponential or linear backoff.

The curl-to-fetch translator converts any curl command into idiomatic code for fetch, axios, node-fetch, got, or ky. Optional env-var extraction hoists API keys and bearer tokens into process.env references; optional retry wrapper adds production-ready exponential or linear backoff with configurable retry status codes.

1. Insight

Insight

The problem this article addresses and why it matters.

Every API doc page ends the same way

Whether you're reading Stripe's API reference, GitHub's REST docs, the OpenAI Platform, or any internal company API, the canonical example is a curl one-liner. It works in any terminal, it's copy-pasteable, it shows the headers and body shape in obvious form. The reader's next step is always the same: translate that curl into a fetch call in their actual code.

The translation looks trivial. It isn't. The curl examples in real API docs use -H "Authorization: Bearer sk_live_..." with a literal key inline — paste that into your TypeScript and you've just committed a production credential to git history. They use multiline JSON body strings that don't always survive the copy-paste through Slack and back. They omit error handling because the docs aren't trying to show you how to ship; they're trying to show you the wire format. And they're written for one HTTP client — the moment you swap fetch for axios or got, you're rewriting from scratch.

Why a translator and not just a code snippet

A textbook curl-to-fetch translator would handle the URL, the method, the headers, and the body. That gets you to a working call. The translator in this article goes further on three specific axes that matter once the generated code lands in a real codebase.

First, it detects credentials in the curl input and hoists them to process.env references — the Authorization header value gets replaced with process.env.STRIPE_SECRET_KEY and a manifest of what was extracted comes back with the result. Second, it produces output for five client libraries (fetch, axios, node-fetch, got, ky), so a developer can paste the same curl into the tool and get the call shape their codebase actually uses. Third, it can wrap the output in a retry loop with configurable backoff, which is the boilerplate everyone writes anyway when the generated code goes into a real service.

What this article delivers

The three-axis translation walked end-to-end: a real curl from the Stripe docs converted to fetch with env extraction, the same input converted to axios with retry-wrapping, and the annotated mode that adds inline comments explaining every option. We cover the edge cases (multipart bodies, file uploads, custom auth schemes) and the cases where the generated code needs human refinement.

2. Intent

Intent

What you will be able to do after reading.

By the end of this article you will be able to:

  • Translate any curl command into idiomatic code for fetch, axios, node-fetch, got, or ky
  • Extract API keys and bearer tokens automatically into process.env references with a manifest of what was hoisted
  • Wrap the generated call in a retry loop with exponential or linear backoff and a configurable set of retry-eligible status codes
  • Add inline comments to the output that document each option (annotated mode) — useful when the consumer is unfamiliar with the target client library
  • Recognise the curl flags that don't translate cleanly (--resolve, --unix-socket, --anyauth) and what to do about them

The Examples section walks through three real translations: a Stripe API call, a GitHub webhook test, and a multipart file upload.

3. Examples

Examples

Annotated code and worked scenarios.

Before / after: a Stripe API call with secret extraction

A canonical curl from the Stripe docs (sk_live_ key inlined as the docs show it):

Before:

curl https://api.stripe.com/v1/customers \
  -H "Authorization: Bearer sk_live_51HxQz9KZvBT6abc...xyz" \
  -d "email=alice@example.com" \
  -d "name=Alice"

After:

curlToFetch({
  curl,
  target:         'fetch',
  typescript:     true,
  extractEnvVars: true,
});
const response = await fetch('https://api.stripe.com/v1/customers', {
  method:  'POST',
  headers: {
    'Authorization': `Bearer ${process.env.STRIPE_SECRET_KEY}`,
    'Content-Type':  'application/x-www-form-urlencoded',
  },
  body: new URLSearchParams({
    email: 'alice@example.com',
    name:  'Alice',
  }).toString(),
});

Plus a manifest of what was extracted:

// envVars: [
//   {
//     name:          'STRIPE_SECRET_KEY',
//     originalValue: 'sk_live_51Hxxxxxxxxxxxxxxxxxxx',  // masked in output
//     placeholder:   'process.env.STRIPE_SECRET_KEY',
//   },
// ]

The original key is masked in the manifest so a developer reviewing the tool's output doesn't have to handle the plaintext credential a second time. The placeholder is what shipped in the code.

Before / after: retry-wrapped output

Same input, with retryConfig added:

curlToFetch({
  curl,
  target:         'fetch',
  typescript:     true,
  extractEnvVars: true,
  retryConfig: {
    maxRetries: 3,
    strategy:   'exponential',
    retryOn:    [429, 502, 503, 504],
  },
});

Output:

async function callStripeCreateCustomer(): Promise<Response> {
  let lastError: unknown;
  for (let attempt = 0; attempt <= 3; attempt++) {
    try {
      const response = await fetch('https://api.stripe.com/v1/customers', {
        method:  'POST',
        headers: {
          'Authorization': `Bearer ${process.env.STRIPE_SECRET_KEY}`,
          'Content-Type':  'application/x-www-form-urlencoded',
        },
        body: new URLSearchParams({
          email: 'alice@example.com',
          name:  'Alice',
        }).toString(),
      });
      if ([429, 502, 503, 504].includes(response.status) && attempt < 3) {
        await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
        continue;
      }
      return response;
    } catch (err) {
      lastError = err;
      if (attempt < 3) {
        await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
        continue;
      }
    }
  }
  throw lastError;
}

The retry loop is the boilerplate every team writes around fetch calls. With this option the generated code is ready to drop into a production service without further wrapping.

Before / after: annotated mode

annotate: true adds inline comments explaining each option — useful for developers unfamiliar with the target client's idioms:

const response = await fetch(
  // curl positional URL argument
  'https://api.stripe.com/v1/customers',
  {
    // -X POST (POST is implicit when -d is present)
    method: 'POST',

    // -H "Authorization: Bearer ..."
    // Secret extracted from header value — see envVars manifest below
    headers: {
      'Authorization': `Bearer ${process.env.STRIPE_SECRET_KEY}`,
      // -d (URL-encoded form data) implies this Content-Type
      'Content-Type': 'application/x-www-form-urlencoded',
    },

    // Two -d flags joined with '&'; URLSearchParams.toString() handles encoding
    body: new URLSearchParams({
      email: 'alice@example.com',
      name:  'Alice',
    }).toString(),
  },
);

Before / after: multipart file upload

A multipart upload — curl uses -F for form fields and file references:

Before:

curl https://api.example.com/upload \
  -H "Authorization: Bearer abc123" \
  -F "file=@./report.pdf" \
  -F "description=Q4 results"

After (target: fetch):

const formData = new FormData();
formData.append('file', new Blob([await fs.promises.readFile('./report.pdf')]), 'report.pdf');
formData.append('description', 'Q4 results');

const response = await fetch('https://api.example.com/upload', {
  method:  'POST',
  headers: {
    'Authorization': `Bearer ${process.env.UPLOAD_API_KEY}`,
  },
  body: formData,
});

Notice the absence of an explicit Content-Type header — fetch sets the multipart boundary automatically when the body is FormData. The translator emits this idiom rather than the brittle "manually set boundary" pattern.

When humans use this

A developer reading a new API's documentation runs the example curl through the tool to get a working call in their codebase's idiom. With extractEnvVars: true the workflow stays safe — they paste the example with the literal key from the docs, the tool hoists it, they update .env.example with the new variable name. Annotated mode is the teaching tool — junior developers learn fetch's quirks by seeing the curl-to-fetch correspondence inline.

When agents use this

Three production patterns:

  • Integration scaffolding. An agent asked to "integrate with the Foo API" reads the API docs, finds the curl examples, runs each through curl_to_fetch with the target client matching the codebase (most codebases prefer fetch or axios), and writes a typed wrapper around each call. The env-extraction is critical here — the agent must not commit credentials, and the tool's behaviour gives a deterministic guarantee rather than asking the LLM to "remember not to inline the key."
  • Library porting. A team migrating from axios to fetch (or vice versa) runs every existing call through the tool to produce a deterministic conversion. The retry-wrapping option keeps the resilience semantics consistent across the migration.
  • Webhook test harness. A multi-step agent generating webhook tests pulls curl from the provider's docs (GitHub, Stripe, Slack), converts each to fetch, wraps with assertions, runs against a local mock.

Edge cases

curl's -b "session=abc123" and --cookie-jar are translated to a Cookie header (set per request) or a credentials: 'include' option for browser-targeted fetch. The translator does NOT generate a cookie-jar manager — that's a stateful concern outside the per-call translation surface.

--anyauth, --digest, --ntlm

Translated to a warning. JavaScript's standard fetch has no built-in support for digest or NTLM auth; the translator points the developer at node-fetch with a custom auth interceptor or a dedicated library (http-auth-utils). The generated call still includes the URL and body — only the auth is left as a // TODO comment.

Binary file downloads (-o, --output)

The output behaviour -o filename.bin doesn't have a fetch equivalent — fetch returns a Response and the developer chooses what to do with the body. The translator emits a comment showing the equivalent pattern (const buffer = await response.arrayBuffer(); await fs.promises.writeFile('filename.bin', Buffer.from(buffer));).

Custom curl resolvers

--resolve example.com:443:127.0.0.1 and --unix-socket /var/run/x.sock have no portable fetch translation. Both return warnings; the generated code uses the original URL and the developer handles the networking concern via an HTTP agent at the runtime layer.

4. Documentation

Documentation

Reference signatures, edge cases, and lookup tables.

Input parameters

Field

Type

Required

Default

Description

curl

string

Curl command string. Multi-line with \ continuations is supported.

target

'fetch' | 'axios' | 'node-fetch' | 'got' | 'ky'

Output client library

typescript

boolean

false

Generate TypeScript with response typing. Otherwise plain JavaScript.

extractEnvVars

boolean

false

Hoist credential-looking values into process.env references

annotate

boolean

false

Add inline comments explaining each generated option

retryConfig

{maxRetries, strategy, retryOn}

Wrap output in a retry loop

Output shape

{
  code:     string;     // The generated call
  imports:  string;     // Required imports (for axios, got, ky targets)
  warnings: string[];   // Flags / features that didn't translate cleanly
  envVars?: Array<{     // when extractEnvVars: true
    name:          string;   // suggested env variable name
    originalValue: string;   // masked
    placeholder:   string;   // what appears in the code
  }>;
}

Env-var extraction heuristic

A header value or query parameter is extracted to process.env when it matches one of:

Pattern

Suggested env name

Authorization: Bearer <opaque>

<HOST>_BEARER_TOKEN derived from the URL host

Authorization: Basic <base64>

<HOST>_BASIC_AUTH

X-API-Key: <opaque>, Api-Key, api_key

<HOST>_API_KEY

URL contains apikey=, access_token=, key=

<HOST>_<PARAM>

Known prefixes — sk_live_, sk_test_, pk_live_, ghp_, xoxb-, xoxp-

Mapped to the relevant service (STRIPE_SECRET_KEY, GITHUB_PAT, SLACK_BOT_TOKEN)

The original value is masked in the output (first 4 chars + ...). The placeholder (process.env.X) is what appears in the generated code.

Error codes

Code

When it fires

Recovery

INPUT_EMPTY

curl is empty

Provide a non-empty curl string

INPUT_MALFORMED

Curl parser failed (unbalanced quotes, unknown flag)

Verify the curl command runs in a real terminal first

UNSUPPORTED_FORMAT

Curl uses TLS client cert flags (--cert, --key) — fetch has no portable equivalent

Use a runtime HTTP agent for the cert; the call shape is still in the output

When NOT to use this tool

Don't use it to translate complex multi-step curl pipelines (curl A → jq B → curl C with the output). Those are shell pipelines, not HTTP calls; the equivalent in code is multiple await statements with explicit data passing, which the translator doesn't emit.

Don't use the retry wrapper as a substitute for idempotency analysis. The retry loop will re-issue any failed request matching the retry status codes — if the underlying API isn't idempotent (a POST that creates a resource), retrying creates duplicates. Use idempotency keys (Idempotency-Key header) for mutating calls.

For very-fine-grained HTTP control (streaming response bodies, custom DNS resolution, connection pooling), the generated code is a starting point. Layer your own HTTP-client config on top.

Performance notes

Typical execution: under 10ms for inputs below 2KB. The curl parser is the dominant cost; output emission is fast. The tool is deterministic — same input + same parameters always produce byte-identical output — so REST responses are Edge-Cache eligible. The env-extraction pattern recognition is a fixed regex set; matching is O(n) in the input length.

Try it now

cURL to Fetch

Convert curl commands to fetch, axios, got, ky, or node-fetch

FAQ

Frequently asked questions

How does env-var extraction decide what to hoist?

It matches header values and query params against credential patterns: Authorization Bearer tokens, X-API-Key headers, known prefixes (sk_live_, ghp_, xoxb-). The original value is masked in the manifest; the placeholder (process.env.X) appears in the generated code.

Does it handle multipart file uploads?

Yes. -F flags translate to FormData.append() calls, with file paths converted to fs.promises.readFile + Blob construction. The translator emits the Content-Type-less idiom (fetch sets the multipart boundary automatically) rather than the brittle manual-boundary pattern.

Can I customise the retry strategy?

retryConfig accepts maxRetries (any positive integer), strategy (exponential or linear), and retryOn (array of HTTP status codes). The generated function wraps the call in an async retry loop with the chosen backoff. For more complex strategies (jitter, custom shouldRetry predicate), use a dedicated retry library and skip this option.

What about authentication schemes that aren't Bearer or Basic?

--digest, --ntlm, --negotiate translate to warnings with TODO comments pointing at the recommended library or HTTP-agent configuration. The URL and body are still translated correctly; just the auth is left for manual handling.