---
title: "How to POST JSON with Python Requests: cURL Equivalents"
description: "To POST JSON with Python Requests, call requests.post(url, json=data) and the Content-Type is set for you. See how data=, params=, headers and cURL flags map."
url: https://proxynet.io/blog/python-requests-post-json
date: 2026-09-25
author: "Acar Diveroli"
category: "Tutorial, Web Scraping"
lang: en
---

# How to POST JSON with Python Requests: cURL Equivalents

A courier company's API documentation shows how to create a shipment as a cURL command: `curl -X POST https://api.example.com/v1/shipments -H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" -d '{"recipient": "Ayse Demir", "weight_kg": 3}'`. It returns `201 Created` in the terminal. You move it into Python as `requests.post(url, data=json.dumps(body), headers={"Authorization": ...})`, and the server answers `415 Unsupported Media Type`: with a string in `data=`, Requests sends no `Content-Type`, and the line that set it stayed in the cURL command.

This guide covers `json=`, `data=` and `files=`, query parameters, headers and Bearer tokens, reading the response, and the Requests argument for each common cURL option. It ends with a checklist for requests that work in cURL but not in Python, and a small API client that retries safely. Every example ran on Python 3.13.9, Requests 2.34.2 and curl 8.21.0 (Windows 11), against httpbin.org and a local server that echoes the bytes it receives.

> **Note: Short answer**
>
> To POST JSON with Python Requests, call `requests.post(url, json=data, timeout=20)`. Requests turns the dict into JSON and sets `Content-Type: application/json` itself. `data=` with a dict sends a form, `data=` with a string sends text with no `Content-Type`, and `files=` sends `multipart/form-data`. From a cURL command, `-H` lines go into `headers=`, `-G` query values into `params=`, `-u` into `auth=`, `-F` into `files=` and `-x` into `proxies=`. If the translated request gets a different answer, check redirects first: cURL follows them only with `-L`, Requests by default.

## How do you send a POST request with Python Requests?

Install the package with `pip install requests`. [PyPI](https://pypi.org/project/requests/) lists 2.34.2, released on 14 May 2026, as the current version; it needs Python 3.10 or newer. Choosing between libraries is covered in [HTTPX vs. Requests vs. AIOHTTP](/blog/httpx-vs-requests-vs-aiohttp).

A POST with a JSON body is one call. httpbin.org/post sends back what it received:

```python
import requests

payload = {"recipient": "Ayse Demir", "weight_kg": 3}
r = requests.post("https://httpbin.org/post", json=payload, timeout=20)

print(r.status_code)                         # 200
print(r.json()["headers"]["Content-Type"])   # application/json
print(r.json()["data"])                      # {"recipient": "Ayse Demir", "weight_kg": 3}
print(r.json()["json"])                      # the same body, parsed back into a dict
```

The `data` field is the body as sent, with a space after each colon and comma. `requests.put()`, `requests.patch()` and `requests.delete()` take the same arguments. Always pass `timeout`: Requests has no default, so a silent server leaves your script waiting ([Max Retries Exceeded With URL](/blog/max-retries-exceeded-with-url) covers timeout errors).

## What is the difference between json=, data= and files=?

Each argument builds the body differently and sets a different `Content-Type`. We sent the same dict four ways:

```python
import json
import requests

url = "https://httpbin.org/post"
body = {"recipient": "Ayse Demir", "weight_kg": 3}

for label, kwargs in [
    ("json=body", {"json": body}),
    ("data=body", {"data": body}),
    ("data=json.dumps(body)", {"data": json.dumps(body)}),
    ("json= and data=", {"json": body, "data": {"note": "x"}}),
]:
    echo = requests.post(url, timeout=20, **kwargs).json()
    print(f"{label:22} {echo['headers'].get('Content-Type')!s:34} form={echo['form']} json={echo['json']}")
```

```text
json=body              application/json                   form={} json={'recipient': 'Ayse Demir', 'weight_kg': 3}
data=body              application/x-www-form-urlencoded  form={'recipient': 'Ayse Demir', 'weight_kg': '3'} json=None
data=json.dumps(body)  None                               form={} json={'recipient': 'Ayse Demir', 'weight_kg': 3}
json= and data=        application/x-www-form-urlencoded  form={'note': 'x'} json=None
```

What the four lines show:

- **`json=`** serialises the dict and sets `Content-Type: application/json`. Use it for JSON APIs.
- **`data=` with a dict** sends a form, and every value becomes text: `weight_kg` arrived as `'3'`.
- **`data=` with a string** sends no `Content-Type`. httpbin parsed it anyway; a strict API answers `415` or `400`. Set the header yourself.
- **`json=` with `data=` or `files=`** loses the JSON without an error. The [Requests quickstart](https://requests.readthedocs.io/en/latest/user/quickstart/) says the `json` parameter is ignored if either `data` or `files` is passed.

Send JSON text through `data=` only when the exact bytes matter. An API that signs the body with an HMAC checks the bytes it receives, and `json=` adds spaces and escapes non-ASCII characters (`"İzmir"` goes out as `"\u0130zmir"`). Build the bytes yourself:

```python
import json
import requests

body = {"recipient": "Ayse Demir", "city": "İzmir", "weight_kg": 3}
raw = json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode("utf-8")

r = requests.post("https://httpbin.org/post", data=raw,
                  headers={"Content-Type": "application/json"}, timeout=20)
print(r.json()["data"])  # {"recipient":"Ayse Demir","city":"İzmir","weight_kg":3}
```

These bytes had the same SHA-256 hash as the body `curl --data-binary @body.json` sent from the same UTF-8 file. Form logins with CSRF tokens are covered in [Sessions and Cookies in Python](/blog/python-login-session-cookies), and `files=` below.

## How do you translate a cURL command to Requests, step by step?

The same request, copied from the courier's web dashboard in the browser's Network panel, has a few extra lines. Finding such a request is covered in [Static vs Dynamic Pages](/blog/static-vs-dynamic-pages).

```bash
curl -X POST "https://api.example.com/v1/shipments?notify=false" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept-Encoding: gzip, deflate, br" \
  -H "Cookie: session=abc123" \
  --data-raw '{"recipient": "Ayse Demir", "weight_kg": 3}'
```

1. **Undo the shell syntax.** Remove the `\` line breaks (`^` in a copy made for Windows `cmd`) and the quotes.
2. **Move the query string into `params=`.** `?notify=false` becomes `params={"notify": "false"}`.
3. **Filter the headers.** Keep what the API needs: `Authorization`, `Accept`, API keys. Drop `Accept-Encoding`, which Requests handles, and `Content-Type` when you use `json=`. Never copy `Host` or `Content-Length`; Requests works them out. Browser lines such as `sec-fetch-*` are rarely needed, and a `Cookie` line belongs in `cookies=` or a `Session`.
4. **Pick the body argument.** JSON becomes `json=` (or `data=` with exact bytes if signed), `-d "a=1&b=2"` becomes `data={"a": "1", "b": "2"}`, and `-F` becomes `files=`.
5. **Pick the method.** `-d`, `--data-raw`, `--json` and `-F` mean POST unless `-X` says otherwise; `-G` makes a GET query.
6. **Add a `timeout=`** and call `r.raise_for_status()`.
7. **Compare.** Send the cURL command and your Python call to `https://httpbin.org/anything` and compare the echoed method, URL, headers and body.

The result, with the token read from an environment variable:

```python
import os
import requests

r = requests.post(
    "https://api.example.com/v1/shipments",
    params={"notify": "false"},
    headers={"Authorization": f"Bearer {os.environ['API_TOKEN']}"},
    json={"recipient": "Ayse Demir", "weight_kg": 3},
    timeout=20,
)
r.raise_for_status()
print(r.status_code, r.headers.get("Location"))
```

[curlconverter](https://github.com/curlconverter/curlconverter) does steps 1 to 5, with Python Requests as its default output. Install it with npm and replace `curl` with `curlconverter` in the command. For the command above, version 4.12.0 moved `notify` into `params`, the cookie into `cookies=` and the body into `json=`, and commented out `Content-Type` and `Accept-Encoding`. It added no timeout, and its README warns that the generated code follows redirects unless the command sets a redirect policy. A browser command holds your live session cookie and token, so convert it locally, not on a website.

## Which Requests argument matches each cURL option?

| cURL option | Requests | What differs |
|---|---|---|
| `-d '{"a":1}'` with `Content-Type: application/json` | `json={"a": 1}` | Requests adds spaces; use `data=` for exact bytes |
| `--json '{"a":1}'` | `json={"a": 1}`, `headers={"Accept": "application/json"}` | `--json` (curl 7.82.0+) also sets `Accept` |
| `-d "a=1&b=2"` | `data={"a": "1", "b": "2"}` | Same bytes |
| `--data-binary @body.json` | `data=open("body.json", "rb")` | `-d @file` would strip line breaks; both of these keep them |
| `-F "file=@report.csv"` | `files={"file": open("report.csv", "rb")}` | curl labels the part `application/octet-stream`; Requests adds a type only from a 3-tuple |
| `-G --data-urlencode "q=kargo takip"` | `params={"q": "kargo takip"}` | Same query: `?q=kargo+takip` |
| `-X PUT` | `requests.put(url, ...)` | Same for `PATCH`, `DELETE` |
| `-H "Name: value"` | `headers={"Name": "value"}` | Values must be strings; an `int` raises `InvalidHeader` |
| `-A "ShipmentSync/1.0"` | `headers={"User-Agent": "ShipmentSync/1.0"}` | Otherwise each tool sends its own name |
| `-b "session=abc123"` | `cookies={"session": "abc123"}` | Same `Cookie` header |
| `-u user:pass` | `auth=("user", "pass")` | Same `Basic` header |
| `-L` | Default | cURL needs `-L`; `allow_redirects=False` turns it off in Requests |
| `--max-redirs 5` | `session.max_redirects = 5` | Requests default: 30 |
| `--connect-timeout 3 -m 20` | `timeout=(3.05, 20)` | `-m` caps the whole transfer; the read timeout is the gap between bytes |
| `-k` / `--cacert ca.pem` | `verify=False` / `verify="ca.pem"` | `verify=False` only in local tests |
| `-x http://user:pass@pr.proxynet.io:8000` | `proxies={"http": url, "https": url}` | See [cURL with Proxy](/blog/curl-proxy) |
| `--compressed` | Nothing | Requests handles compression itself |
| `-I` | `requests.head(url)` | No redirects followed for `HEAD` |
| `-i` / `-v` | `r.headers` / `r.request.headers` | What was received and sent |

Each option is described in the [curl manual](https://curl.se/docs/manpage.html). A new proxy on every request is a separate job ([How to Rotate Proxies in Python](/blog/how-to-rotate-proxies-in-python)).

## How do you pass query parameters and headers?

Give query values to `params=` as a dict:

```python
import requests

params = {"q": "kargo takip", "status": ["pending", "shipped"], "sort": None}
r = requests.get("https://httpbin.org/get", params=params, timeout=20)
print(r.url)  # https://httpbin.org/get?q=kargo+takip&status=pending&status=shipped
```

A list repeats the key, `None` is left out, and spaces and non-ASCII characters are encoded for you. A query string already in the URL stays, with `params=` added after it. Paging through results is covered in [Pagination in Web Scraping](/blog/pagination-web-scraping).

Headers go into a dict of strings, with the token read from the environment:

```python
import os
import requests

headers = {
    "Authorization": f"Bearer {os.environ['API_TOKEN']}",
    "Accept": "application/json",
}
r = requests.get("https://httpbin.org/headers", headers=headers, timeout=20)
print(r.json()["headers"]["Authorization"])  # Bearer <your token>

r = requests.get("https://httpbin.org/basic-auth/user/pass", auth=("user", "pass"), timeout=20)
print(r.status_code)  # 200
```

Headers every call needs go on a `requests.Session` once, through `session.headers`. Three rules from the Requests documentation, all confirmed in our tests:

- **`.netrc` beats `headers=`.** A `.netrc` entry for the host replaced our `Bearer` header with a `Basic` one; `auth=` beats both.
- **Authorization stays on the original host.** After a redirect from `127.0.0.1` to `localhost`, the header was gone.
- **Values are strings.** `headers={"X-Page": 2}` raised `InvalidHeader`.

Without your own `User-Agent`, Requests sends `python-requests/2.34.2`. What to put there is in [What Is a User Agent?](/blog/what-is-user-agent), and why one client's headers should stay consistent in [How to Scrape Websites Without Getting Blocked](/blog/web-scraping-without-getting-blocked).

## How do you read the response and catch HTTP errors?

A `Response` gives you `r.status_code`, `r.headers` (a dict that ignores letter case), `r.content` (raw bytes), `r.text` (the bytes decoded with `r.encoding`) and `r.json()`. Broken characters in `r.text` mean a wrong encoding guess ([Python Encoding Errors](/blog/python-unicode-encoding-errors)).

The Requests documentation warns that a successful `r.json()` does not mean a successful request: a server can send a JSON error body with a `500`. Check the status first:

```python
import requests

r = requests.get("https://httpbin.org/status/404", timeout=20)
try:
    r.raise_for_status()
except requests.HTTPError as exc:
    print(exc)  # 404 Client Error: NOT FOUND for url: https://httpbin.org/status/404
```

`raise_for_status()` raises `HTTPError` for any `4xx` or `5xx`. When the body is empty or HTML, `r.json()` fails with `JSONDecodeError` ([JSONDecodeError: Expecting Value](/blog/jsondecodeerror-expecting-value) lists the causes). Which codes to retry is in [HTTP Status Codes in Web Scraping](/blog/http-status-codes-web-scraping).

## How do you upload and download files with Requests?

`files=` builds a `multipart/form-data` body. A 3-tuple sets the file name and part type, and `data=` fields travel as extra parts. Since `json=` is ignored next to `files=`, send JSON as its own part:

```python
import json
import requests

with open("report.csv", "rb") as f:
    files = {
        "file": ("report.csv", f, "text/csv"),
        "meta": (None, json.dumps({"source": "warehouse"}), "application/json"),
    }
    r = requests.post("https://httpbin.org/post", files=files, data={"note": "daily"}, timeout=20)

print(r.json()["files"])  # {'file': 'sku,price\n1001,19.90\n'}
print(r.json()["form"])   # {'meta': '{"source": "warehouse"}', 'note': 'daily'}
```

Open the file in binary mode (`"rb"`): the documentation explains that Requests may set `Content-Length` to the file's byte count, and text mode can make it wrong. For very large uploads, the same page points to the `requests-toolbelt` package, which streams the body.

For downloads, `stream=True` keeps a large body out of memory:

```python
import requests

url = "https://example.com/export.csv"  # replace with your file's URL
with requests.get(url, stream=True, timeout=(3.05, 60)) as r:
    r.raise_for_status()
    with open("export.csv", "wb") as f:
        for chunk in r.iter_content(chunk_size=64 * 1024):
            f.write(chunk)
```

Downloading many files from one page is covered in [How to Download All Images From a Website](/blog/download-all-images-from-website).

## Why does a request that works in cURL get a different answer in Requests?

Usually the two requests differ. Check in this order:

1. **Redirects.** cURL stops at a `3xx` without `-L`; Requests follows it for every method except `HEAD`. When either tool follows, a POST answered with `301`, `302` or `303` becomes a GET without a body, and `307` or `308` keep the POST, in line with [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.4). One exception: with `-X POST` and `-L`, cURL sent a POST without the body after a `302`; `--follow` (curl 8.16.0+) switches to GET. Look at `r.history`, or pass `allow_redirects=False` and read `Location`.
2. **Default headers.** curl 8.21.0 sent `User-Agent: curl/8.21.0`, `Accept: */*` and no `Accept-Encoding`. Requests sent `python-requests/2.34.2`, `Accept: */*`, `Connection: keep-alive` and `Accept-Encoding: gzip, deflate` (plus `br` with brotli installed, `zstd` on Python 3.14). Compare with `r.request.headers`.
3. **HTTP version.** Requests speaks HTTP/1.1 only: `r.raw.version` returned `11` for an HTTPS site. curl negotiates HTTP/2 for HTTPS by default when its build supports it (`curl -V` lists `HTTP2`), and `-w "%{http_version}"` prints the version used. Our Windows build lacks it and used `1.1`.
4. **Environment.** With `Session.trust_env` at its default `True`, Requests reads `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY`, the system proxy settings on Windows and macOS when no variable is set, and `.netrc`. cURL reads the variables (`http_proxy` only in lower case) but not the system settings, and `.netrc` only with `--netrc`. `requests.utils.get_environ_proxies(url)` shows what Requests picked; the variables are explained in [Using a Proxy with wget](/blog/wget-proxy).
5. **Certificates.** Requests uses the `certifi` bundle; curl on Windows with Schannel uses the Windows store. Behind a company proxy that inspects TLS, cURL can pass while Requests raises `SSLError` (fix in [Max Retries Exceeded With URL](/blog/max-retries-exceeded-with-url)).
6. **Body bytes.** `json=` re-serialises the body, and `-d @file` strips line breaks. Hash both bodies if the API signs them.
7. **The wire.** A local [MITM proxy](/blog/mitm-proxy) shows both requests side by side.

If everything matches and the answer still differs, the site is judging the client itself, for example its TLS handshake, which headers do not change. [Cloudflare Scraper](/blog/cloudflare-scraper) explains how to read such an answer, and [TLS Fingerprinting and JA3](/blog/tls-fingerprinting) what the handshake reveals. The way forward is the site's official API or the owner's permission; we do not cover tools that disguise a script as a browser.

## Full example: a small API client with safe retries

The script keeps the Bearer token and shared headers on one `Session`, sends `params=` with a GET and `json=` with a POST, and checks every status. It retries only the GET: [MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods/POST) describes POST as not idempotent, so a repeat can create a second shipment. The [urllib3 `Retry` class](https://urllib3.readthedocs.io/en/stable/reference/urllib3.util.html) already leaves POST out by default; the script spells it out.

```python
"""A small API client: GET with params, POST with json=, retries for GET only."""
import os

import requests
from requests.adapters import HTTPAdapter
from urllib3.util import Retry

API = os.environ.get("API_BASE", "https://api.example.com/v1")
TIMEOUT = (3.05, 20)  # connect timeout, read timeout (seconds)

def make_session():
    session = requests.Session()
    session.headers.update({
        "Authorization": f"Bearer {os.environ['API_TOKEN']}",  # never hard-code the token
        "Accept": "application/json",
        "User-Agent": "ShipmentSync/1.0 (+https://example.com/contact)",
    })
    retry = Retry(
        total=3,
        backoff_factor=0.5,
        status_forcelist=[502, 503, 504],
        allowed_methods=["GET"],  # a repeated POST could create the same shipment twice
        raise_on_status=False,    # hand back the last response, raise_for_status() reports it
    )
    adapter = HTTPAdapter(max_retries=retry)
    session.mount("https://", adapter)
    session.mount("http://", adapter)
    proxy = os.environ.get("PROXY_URL")  # optional, e.g. http://user:pass@pr.proxynet.io:8000
    if proxy:
        session.proxies = {"http": proxy, "https": proxy}
        session.trust_env = False  # otherwise HTTPS_PROXY or the system proxy wins over session.proxies
    return session

def list_shipments(session, status="pending", page=1):
    r = session.get(f"{API}/shipments", params={"status": status, "page": page}, timeout=TIMEOUT)
    r.raise_for_status()
    return r.json()

def create_shipment(session, recipient, weight_kg):
    body = {"recipient": recipient, "weight_kg": weight_kg}
    r = session.post(f"{API}/shipments", json=body, timeout=TIMEOUT)
    r.raise_for_status()
    return r.status_code, r.headers.get("Location"), r.json()

if __name__ == "__main__":
    with make_session() as s:
        try:
            print(list_shipments(s))
            print(create_shipment(s, "Ayse Demir", 3))
        except requests.HTTPError as exc:
            print("API error:", exc)
```

Against a local server that imitates the API, the GET returned the list and the POST `201` with a `Location` header. When the server answered `503`, the GET went out four times (waiting 0, 1 and 2 seconds between tries) before `raise_for_status()` reported it; a POST to the same address went out once. Through a local test proxy set in `PROXY_URL`, both calls worked, and a wrong password gave `407 Proxy Authentication Required`.

urllib3 also honours `Retry-After` on a `503` by default; handling `429` is covered in [HTTP Status Codes in Web Scraping](/blog/http-status-codes-web-scraping), and many parallel calls in [Concurrency vs Parallelism](/blog/concurrency-vs-parallelism).

## Use cases

- **Product or price data from an API** the site offers ([data scraping](/data-scraping)).
- **A JSON endpoint found in the Network panel**, called instead of rendering the page ([Static vs Dynamic Pages](/blog/static-vs-dynamic-pages)).
- **Crawling the pages behind API results** at a polite rate ([Python Web Crawler](/blog/python-web-crawler)).
- **Testing your own webhook or internal service** from a script rather than a GUI tool ([Postman with a proxy](/blog/postman-proxy)).
- **Checking an API's answer for another country** through a [Residential Proxy](https://proxynet.io/residential-proxy) there.
- **An API that accepts only registered IP addresses**, called from one fixed exit ([Static IP for API Access](/blog/static-ip-for-api-access)).
- **The same request in Node.js** with fetch or Axios ([cURL in JavaScript](/blog/curl-in-javascript)).

## Common mistakes

- **`data=json.dumps(body)` without `Content-Type`.** The server cannot tell it is JSON; use `json=body`.
- **`json=` and `data=` in one call.** The JSON is dropped silently.
- **No `timeout`.** One silent server stops the script.
- **Gluing the query string by hand.** Spaces and non-ASCII characters break the URL.
- **`r.json()` before the status check.** A `500` with a JSON error body parses fine.
- **Uploads opened in text mode.** Use `"rb"`.
- **Copying `Host` and `Content-Length`.** Requests sent a copied `Host` unchanged, so a test against another server still named the old one. A copied `Content-Length` on a GET without a body made our server wait until the read timeout.
- **Browser commands pasted into online converters.** They contain your session cookie and token.
- **`verify=False` in production.** It turns off certificate checks.
- **Retrying POST blindly.** A retry after a timeout can create a second record.

## Decision guide

| Need | Recommendation |
|---|---|
| JSON body for an API | `requests.post(url, json=data, timeout=20)`, no manual `Content-Type` |
| Signed body, byte for byte | `data=` with bytes you serialised, plus `Content-Type` |
| Plain form (not a login) | `data=` with a dict; logins and CSRF in the sessions guide |
| File with extra fields | `files=` plus `data=`; JSON as its own `application/json` part |
| Quick cURL translation | The table above, or curlconverter on your machine |
| Works in cURL, not in Python | Check `r.history`, `r.request.headers`, `trust_env`, HTTP version |
| HTTP/2 or async calls | HTTPX; AIOHTTP for async only |

## Frequently asked questions

### Do I need to set the Content-Type header when posting JSON with Requests?

Not with `json=`: Requests sets `Content-Type: application/json` itself. You need it when you pass JSON text through `data=`, because a string in `data=` goes out without one, and a strict API then answers `415 Unsupported Media Type` or `400`.

### What is the difference between json= and data=json.dumps() in Requests?

Both send JSON text, but only `json=` adds the `Content-Type` header. The bytes can differ too: `json=` writes spaces after colons and commas and escapes non-ASCII characters. Use `json=` by default, and `data=` with your own bytes when an API signs the body.

### How do I send a Bearer token with Python Requests?

Use `headers={"Authorization": f"Bearer {token}"}`, or set it once in `session.headers`. Read the token from an environment variable. If a `.netrc` file has credentials for the same host, Requests uses those instead; `Session.trust_env = False` turns that off.

### Can I convert a cURL command to Python automatically?

Yes. curlconverter turns a cURL command into Requests code and runs on your own machine. Review the output: it adds no timeout, and Requests follows redirects the cURL command did not. Keep commands with cookies or tokens away from online converters.

### Does Requests follow redirects, and why does my POST turn into a GET?

Requests follows redirects for every method except `HEAD`. After a `301`, `302` or `303`, a POST becomes a GET and loses its body, as in browsers; after `307` or `308`, it stays a POST. `allow_redirects=False` stops at the first answer.

### Does Python Requests support HTTP/2?

No. Requests speaks HTTP/1.1 only; in our test `r.raw.version` returned `11` for an HTTPS site. [HTTPX](https://www.python-httpx.org/http2/) supports HTTP/2 when you install `httpx[http2]` and create the client with `http2=True`; it is off by default.

## Summary

For a JSON API, `requests.post(url, json=data, timeout=20)` is the whole job: Requests serialises the body and sets the header. `data=` sends forms or exact bytes, `files=` multipart bodies, and `params=` the query string. A cURL command maps onto these arguments option by option; when the answers still differ, check redirects, default headers, the HTTP version and the environment, in that order. When an API has to be called from a given country or a fixed address, compare the options on our [proxy services](/proxy) page.
