---
title: "POST con JSON en Python Requests: equivalencias de cURL"
description: "Para enviar JSON por POST con Python Requests, usa requests.post(url, json=data) y el Content-Type se pone solo. Así encajan data=, params=, cabeceras y cURL."
url: https://proxynet.io/es/blog/python-requests-post-json
date: 2026-09-25
author: "Acar Diveroli"
category: "Tutoriales, Web scraping"
lang: es
---

# POST con JSON en Python Requests: equivalencias de cURL

La documentación de la API de una empresa de mensajería muestra cómo crear un envío con un comando cURL: `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}'`. En la terminal devuelve `201 Created`. Lo pasas a Python como `requests.post(url, data=json.dumps(body), headers={"Authorization": ...})` y el servidor responde `415 Unsupported Media Type`: con una cadena en `data=`, Requests no envía `Content-Type`, y la línea que lo definía se quedó en el comando cURL.

Esta guía explica `json=`, `data=` y `files=`, los parámetros de consulta, las cabeceras y los tokens Bearer, cómo leer la respuesta y qué argumento de Requests corresponde a cada opción habitual de cURL. Termina con una lista de comprobación para las peticiones que funcionan en cURL pero no en Python y con un pequeño cliente de API que reintenta de forma segura. Todos los ejemplos se ejecutaron con Python 3.13.9, Requests 2.34.2 y curl 8.21.0 (Windows 11), contra httpbin.org y un servidor local que devuelve los bytes que recibe.

> **Nota: Respuesta breve**
>
> Para enviar JSON por POST con Python Requests, llama a `requests.post(url, json=data, timeout=20)`. Requests convierte el dict en JSON y pone él mismo `Content-Type: application/json`. `data=` con un dict envía un formulario, `data=` con una cadena envía texto sin `Content-Type`, y `files=` envía `multipart/form-data`. Desde un comando cURL, las líneas `-H` van a `headers=`, los valores de consulta de `-G` a `params=`, `-u` a `auth=`, `-F` a `files=` y `-x` a `proxies=`. Si la petición traducida recibe otra respuesta, revisa primero las redirecciones: cURL solo las sigue con `-L`, Requests lo hace por defecto.

## ¿Cómo se envía una petición POST con Python Requests?

Instala el paquete con `pip install requests`. [PyPI](https://pypi.org/project/requests/) indica como versión actual la 2.34.2, publicada el 14 de mayo de 2026; requiere Python 3.10 o posterior. Cómo elegir entre librerías se explica en [HTTPX, Requests y AIOHTTP: comparativa](/es/blog/httpx-vs-requests-vs-aiohttp).

Un POST con cuerpo JSON es una sola llamada. httpbin.org/post devuelve lo que recibió:

```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"])                      # el mismo cuerpo, convertido de nuevo en dict
```

El campo `data` es el cuerpo tal como se envió, con un espacio después de cada dos puntos y de cada coma. `requests.put()`, `requests.patch()` y `requests.delete()` aceptan los mismos argumentos. Pasa siempre `timeout`: Requests no tiene valor por defecto, así que un servidor que no responde deja tu script esperando ([Max Retries Exceeded With URL](/es/blog/max-retries-exceeded-with-url) trata los errores de tiempo de espera).

## ¿Qué diferencia hay entre json=, data= y files=?

Cada argumento construye el cuerpo de otra forma y pone un `Content-Type` distinto. Enviamos el mismo dict de cuatro maneras:

```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
```

Lo que muestran las cuatro líneas:

- **`json=`** serializa el dict y pone `Content-Type: application/json`. Úsalo con las API JSON.
- **`data=` con un dict** envía un formulario, y todos los valores se convierten en texto: `weight_kg` llegó como `'3'`.
- **`data=` con una cadena** no envía `Content-Type`. httpbin lo interpretó igualmente; una API estricta responde `415` o `400`. Pon tú la cabecera.
- **`json=` junto con `data=` o `files=`** pierde el JSON sin dar ningún error. La [guía rápida de Requests](https://requests.readthedocs.io/en/latest/user/quickstart/) indica que el parámetro `json` se ignora si se pasa `data` o `files`.

Envía texto JSON por `data=` solo cuando importan los bytes exactos. Una API que firma el cuerpo con un HMAC comprueba los bytes que recibe, y `json=` añade espacios y escapa los caracteres no ASCII (`"İzmir"` sale como `"\u0130zmir"`). Construye tú los bytes:

```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}
```

Estos bytes dieron el mismo hash SHA-256 que el cuerpo que envió `curl --data-binary @body.json` a partir del mismo archivo UTF-8. Los inicios de sesión con formulario y token CSRF se tratan en [Sesiones y cookies en Python](/es/blog/python-login-session-cookies), y `files=`, más abajo.

## ¿Cómo se traduce un comando cURL a Requests, paso a paso?

La misma petición, copiada desde la pestaña Red del navegador en el panel web de la empresa de mensajería, trae algunas líneas más. Cómo encontrar una petición así se explica en [Páginas estáticas y dinámicas en web scraping](/es/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. **Deshaz la sintaxis del shell.** Quita los saltos de línea con `\` (`^` en una copia hecha para `cmd` de Windows) y las comillas.
2. **Pasa la cadena de consulta a `params=`.** `?notify=false` se convierte en `params={"notify": "false"}`.
3. **Filtra las cabeceras.** Conserva lo que necesita la API: `Authorization`, `Accept`, claves de API. Quita `Accept-Encoding`, que ya gestiona Requests, y `Content-Type` cuando usas `json=`. No copies nunca `Host` ni `Content-Length`; Requests los calcula. Las líneas propias del navegador, como `sec-fetch-*`, rara vez hacen falta, y una línea `Cookie` va en `cookies=` o en una `Session`.
4. **Elige el argumento del cuerpo.** El JSON va a `json=` (o a `data=` con bytes exactos si el cuerpo va firmado), `-d "a=1&b=2"` pasa a `data={"a": "1", "b": "2"}` y `-F` pasa a `files=`.
5. **Elige el método.** `-d`, `--data-raw`, `--json` y `-F` significan POST salvo que `-X` indique otra cosa; `-G` convierte los datos en una consulta GET.
6. **Añade un `timeout=`** y llama a `r.raise_for_status()`.
7. **Compara.** Envía el comando cURL y tu llamada de Python a `https://httpbin.org/anything` y compara el método, la URL, las cabeceras y el cuerpo que te devuelve.

El resultado, con el token leído de una variable de entorno:

```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) hace los pasos 1 a 5 y genera Python Requests como salida por defecto. Instálalo con npm y sustituye `curl` por `curlconverter` en el comando. Con el comando anterior, la versión 4.12.0 pasó `notify` a `params`, la cookie a `cookies=` y el cuerpo a `json=`, y dejó comentadas `Content-Type` y `Accept-Encoding`. No añadió ningún timeout, y su README advierte de que el código generado sigue las redirecciones salvo que el comando defina una política de redirección. Un comando copiado del navegador contiene tu cookie de sesión activa y tu token, así que conviértelo en tu propio equipo, no en un sitio web.

## ¿Qué argumento de Requests corresponde a cada opción de cURL?

| Opción de cURL | Requests | Qué cambia |
|---|---|---|
| `-d '{"a":1}'` con `Content-Type: application/json` | `json={"a": 1}` | Requests añade espacios; usa `data=` para bytes exactos |
| `--json '{"a":1}'` | `json={"a": 1}`, `headers={"Accept": "application/json"}` | `--json` (curl 7.82.0+) también pone `Accept` |
| `-d "a=1&b=2"` | `data={"a": "1", "b": "2"}` | Mismos bytes |
| `--data-binary @body.json` | `data=open("body.json", "rb")` | `-d @file` quitaría los saltos de línea; estas dos formas los conservan |
| `-F "file=@report.csv"` | `files={"file": open("report.csv", "rb")}` | curl marca la parte como `application/octet-stream`; Requests solo añade un tipo si usas una tupla de 3 |
| `-G --data-urlencode "q=kargo takip"` | `params={"q": "kargo takip"}` | Misma consulta: `?q=kargo+takip` |
| `-X PUT` | `requests.put(url, ...)` | Igual con `PATCH` y `DELETE` |
| `-H "Name: value"` | `headers={"Name": "value"}` | Los valores deben ser cadenas; un `int` lanza `InvalidHeader` |
| `-A "ShipmentSync/1.0"` | `headers={"User-Agent": "ShipmentSync/1.0"}` | Si no, cada herramienta envía su propio nombre |
| `-b "session=abc123"` | `cookies={"session": "abc123"}` | Misma cabecera `Cookie` |
| `-u user:pass` | `auth=("user", "pass")` | Misma cabecera `Basic` |
| `-L` | Por defecto | cURL necesita `-L`; en Requests, `allow_redirects=False` lo desactiva |
| `--max-redirs 5` | `session.max_redirects = 5` | Valor por defecto en Requests: 30 |
| `--connect-timeout 3 -m 20` | `timeout=(3.05, 20)` | `-m` limita toda la transferencia; el timeout de lectura es la pausa máxima entre bytes |
| `-k` / `--cacert ca.pem` | `verify=False` / `verify="ca.pem"` | `verify=False` solo en pruebas locales |
| `-x http://user:pass@pr.proxynet.io:8000` | `proxies={"http": url, "https": url}` | Ver [cURL con proxy](/es/blog/curl-proxy) |
| `--compressed` | Nada | Requests gestiona la compresión por sí mismo |
| `-I` | `requests.head(url)` | Con `HEAD` no se siguen redirecciones |
| `-i` / `-v` | `r.headers` / `r.request.headers` | Lo que se recibió y lo que se envió |

Cada opción se describe en el [manual de curl](https://curl.se/docs/manpage.html). Usar un proxy distinto en cada petición es otro trabajo ([Cómo rotar proxies en Python](/es/blog/how-to-rotate-proxies-in-python)).

## ¿Cómo se pasan los parámetros de consulta y las cabeceras?

Pasa los valores de consulta a `params=` como 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
```

Una lista repite la clave, `None` se omite, y los espacios y los caracteres no ASCII se codifican solos. Si la URL ya trae una cadena de consulta, se conserva y `params=` se añade detrás. Cómo recorrer páginas de resultados se explica en la guía de [paginación en web scraping](/es/blog/pagination-web-scraping).

Las cabeceras van en un dict de cadenas, con el token leído del entorno:

```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 <tu token>

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

Las cabeceras que necesita cada llamada se ponen una sola vez en una `requests.Session`, mediante `session.headers`. Tres reglas de la documentación de Requests, todas confirmadas en nuestras pruebas:

- **`.netrc` gana a `headers=`.** Una entrada de `.netrc` para el host sustituyó nuestra cabecera `Bearer` por una `Basic`; `auth=` gana a las dos.
- **Authorization se queda en el host original.** Tras una redirección de `127.0.0.1` a `localhost`, la cabecera había desaparecido.
- **Los valores son cadenas.** `headers={"X-Page": 2}` lanzó `InvalidHeader`.

Si no defines tu propio `User-Agent`, Requests envía `python-requests/2.34.2`. Qué poner ahí se explica en [¿Qué es el User-Agent?](/es/blog/what-is-user-agent), y por qué las cabeceras de un mismo cliente deben ser coherentes, en [Cómo hacer web scraping sin que te bloqueen](/es/blog/web-scraping-without-getting-blocked).

## ¿Cómo se lee la respuesta y se capturan los errores HTTP?

Un `Response` te da `r.status_code`, `r.headers` (un dict que no distingue entre mayúsculas y minúsculas), `r.content` (los bytes en bruto), `r.text` (los bytes decodificados con `r.encoding`) y `r.json()`. Si ves caracteres rotos en `r.text`, la codificación se adivinó mal ([Codificación en Python](/es/blog/python-unicode-encoding-errors)).

La documentación de Requests advierte de que un `r.json()` que funciona no significa que la petición haya ido bien: un servidor puede enviar un cuerpo de error en JSON junto con un `500`. Comprueba primero el estado:

```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()` lanza `HTTPError` con cualquier `4xx` o `5xx`. Cuando el cuerpo está vacío o es HTML, `r.json()` falla con `JSONDecodeError` ([Cómo solucionar JSONDecodeError: Expecting Value](/es/blog/jsondecodeerror-expecting-value) reúne las causas). Qué códigos conviene reintentar se explica en [Códigos de estado HTTP en web scraping](/es/blog/http-status-codes-web-scraping).

## ¿Cómo se suben y descargan archivos con Requests?

`files=` construye un cuerpo `multipart/form-data`. Una tupla de 3 fija el nombre del archivo y el tipo de la parte, y los campos de `data=` viajan como partes adicionales. Como `json=` se ignora junto a `files=`, envía el JSON como una parte propia:

```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'}
```

Abre el archivo en modo binario (`"rb"`): la documentación explica que Requests puede poner en `Content-Length` el número de bytes del archivo, y el modo texto puede hacer que ese valor sea incorrecto. Para subidas muy grandes, la misma página remite al paquete `requests-toolbelt`, que envía el cuerpo en streaming.

Para las descargas, `stream=True` evita cargar en memoria un cuerpo grande:

```python
import requests

url = "https://example.com/export.csv"  # sustitúyela por la URL de tu archivo
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)
```

Descargar muchos archivos de una misma página se explica en [Cómo descargar todas las imágenes de una web](/es/blog/download-all-images-from-website).

## ¿Por qué una petición que funciona en cURL recibe otra respuesta en Requests?

Normalmente, porque las dos peticiones no son iguales. Revisa en este orden:

1. **Redirecciones.** Sin `-L`, cURL se detiene en un `3xx`; Requests lo sigue con todos los métodos salvo `HEAD`. Cuando cualquiera de las dos herramientas sigue la redirección, un POST que recibe `301`, `302` o `303` se convierte en un GET sin cuerpo, y con `307` o `308` sigue siendo POST, de acuerdo con [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.4). Hay una excepción: con `-X POST` y `-L`, cURL envió un POST sin el cuerpo tras un `302`; `--follow` (curl 8.16.0+) cambia a GET. Mira `r.history`, o pasa `allow_redirects=False` y lee `Location`.
2. **Cabeceras por defecto.** curl 8.21.0 envió `User-Agent: curl/8.21.0`, `Accept: */*` y ningún `Accept-Encoding`. Requests envió `python-requests/2.34.2`, `Accept: */*`, `Connection: keep-alive` y `Accept-Encoding: gzip, deflate` (más `br` con brotli instalado y `zstd` en Python 3.14). Compara con `r.request.headers`.
3. **Versión de HTTP.** Requests solo habla HTTP/1.1: `r.raw.version` devolvió `11` en un sitio HTTPS. curl negocia HTTP/2 por defecto con HTTPS cuando su compilación lo admite (`curl -V` muestra `HTTP2`), y `-w "%{http_version}"` imprime la versión usada. Nuestra compilación de Windows no lo incluye y usó `1.1`.
4. **Entorno.** Con `Session.trust_env` en su valor por defecto, `True`, Requests lee `HTTP_PROXY`, `HTTPS_PROXY` y `NO_PROXY`, la configuración de proxy del sistema en Windows y macOS cuando no hay ninguna variable, y `.netrc`. cURL lee las variables (`http_proxy` solo en minúsculas), pero no la configuración del sistema, y `.netrc` solo con `--netrc`. `requests.utils.get_environ_proxies(url)` muestra qué eligió Requests; las variables se explican en [Proxy con wget](/es/blog/wget-proxy).
5. **Certificados.** Requests usa el paquete `certifi`; curl en Windows con Schannel usa el almacén de Windows. Detrás de un proxy corporativo que inspecciona TLS, cURL puede pasar mientras Requests lanza `SSLError` (la solución está en [Max Retries Exceeded With URL](/es/blog/max-retries-exceeded-with-url)).
6. **Bytes del cuerpo.** `json=` vuelve a serializar el cuerpo, y `-d @file` quita los saltos de línea. Calcula el hash de los dos cuerpos si la API los firma.
7. **Lo que pasa por la red.** Un [proxy MITM](/es/blog/mitm-proxy) local muestra las dos peticiones una al lado de la otra.

Si todo coincide y la respuesta sigue siendo distinta, el sitio está juzgando al propio cliente, por ejemplo su handshake TLS, que las cabeceras no cambian. [Cloudflare y scraping](/es/blog/cloudflare-scraper) explica cómo leer una respuesta así, y [Huella TLS y JA3](/es/blog/tls-fingerprinting), qué revela el handshake. El camino es la API oficial del sitio o el permiso de su propietario; no tratamos herramientas que hacen pasar un script por un navegador.

## Ejemplo completo: un pequeño cliente de API con reintentos seguros

El script guarda el token Bearer y las cabeceras comunes en una sola `Session`, envía `params=` con un GET y `json=` con un POST, y comprueba cada estado. Solo reintenta el GET: [MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods/POST) describe POST como no idempotente, así que repetirlo puede crear un segundo envío. La [clase `Retry` de urllib3](https://urllib3.readthedocs.io/en/stable/reference/urllib3.util.html) ya deja fuera POST por defecto; el script lo indica de forma explícita.

```python
"""Un pequeño cliente de API: GET con params, POST con json=, reintentos solo para GET."""
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)  # timeout de conexión, timeout de lectura (segundos)

def make_session():
    session = requests.Session()
    session.headers.update({
        "Authorization": f"Bearer {os.environ['API_TOKEN']}",  # nunca escribas el token en el código
        "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"],  # un POST repetido podría crear el mismo envío dos veces
        raise_on_status=False,    # devuelve la última respuesta; raise_for_status() la notifica
    )
    adapter = HTTPAdapter(max_retries=retry)
    session.mount("https://", adapter)
    session.mount("http://", adapter)
    proxy = os.environ.get("PROXY_URL")  # opcional, p. ej. http://user:pass@pr.proxynet.io:8000
    if proxy:
        session.proxies = {"http": proxy, "https": proxy}
        session.trust_env = False  # si no, HTTPS_PROXY o el proxy del sistema ganan a 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)
```

Contra un servidor local que imita la API, el GET devolvió la lista y el POST devolvió `201` con una cabecera `Location`. Cuando el servidor respondió `503`, el GET salió cuatro veces (con esperas de 0, 1 y 2 segundos entre intentos) antes de que `raise_for_status()` lo notificara; un POST a la misma dirección salió una sola vez. A través de un proxy de prueba local definido en `PROXY_URL`, las dos llamadas funcionaron, y una contraseña incorrecta dio `407 Proxy Authentication Required`.

urllib3 también respeta `Retry-After` en un `503` por defecto; cómo tratar un `429` se explica en [Códigos de estado HTTP en web scraping](/es/blog/http-status-codes-web-scraping), y cómo lanzar muchas llamadas en paralelo, en [Concurrencia y paralelismo en web scraping](/es/blog/concurrency-vs-parallelism).

## Casos de uso

- **Datos de productos o precios desde una API** que ofrece el propio sitio ([extracción de datos](/es/data-scraping)).
- **Un endpoint JSON encontrado en la pestaña Red**, llamado en lugar de renderizar la página ([Páginas estáticas y dinámicas](/es/blog/static-vs-dynamic-pages)).
- **Rastrear las páginas que hay detrás de los resultados de una API** a un ritmo moderado ([rastreador web en Python](/es/blog/python-web-crawler)).
- **Probar tu propio webhook o servicio interno** desde un script en lugar de una herramienta gráfica ([Postman con proxy](/es/blog/postman-proxy)).
- **Comprobar la respuesta de una API para otro país** con los [Proxies residenciales](https://proxynet.io/es/residential-proxy) de ese país.
- **Una API que solo acepta direcciones IP registradas**, llamada desde una salida fija ([IP estática para API](/es/blog/static-ip-for-api-access)).
- **La misma petición en Node.js** con fetch o Axios ([cURL en JavaScript](/es/blog/curl-in-javascript)).

## Errores comunes

- **`data=json.dumps(body)` sin `Content-Type`.** El servidor no sabe que es JSON; usa `json=body`.
- **`json=` y `data=` en la misma llamada.** El JSON se descarta sin avisar.
- **Sin `timeout`.** Un solo servidor que no responde detiene el script.
- **Montar la cadena de consulta a mano.** Los espacios y los caracteres no ASCII rompen la URL.
- **`r.json()` antes de comprobar el estado.** Un `500` con un cuerpo de error en JSON se interpreta sin problema.
- **Subidas abiertas en modo texto.** Usa `"rb"`.
- **Copiar `Host` y `Content-Length`.** Requests envió sin cambios un `Host` copiado, así que una prueba contra otro servidor seguía nombrando el anterior. Un `Content-Length` copiado en un GET sin cuerpo hizo que nuestro servidor esperara hasta el timeout de lectura.
- **Comandos del navegador pegados en conversores en línea.** Contienen tu cookie de sesión y tu token.
- **`verify=False` en producción.** Desactiva la comprobación de certificados.
- **Reintentar POST a ciegas.** Un reintento tras un timeout puede crear un segundo registro.

## Guía de decisión

| Necesidad | Recomendación |
|---|---|
| Cuerpo JSON para una API | `requests.post(url, json=data, timeout=20)`, sin `Content-Type` manual |
| Cuerpo firmado, byte a byte | `data=` con bytes que serializaste tú, más `Content-Type` |
| Formulario simple (no un inicio de sesión) | `data=` con un dict; inicios de sesión y CSRF en la guía de sesiones |
| Archivo con campos adicionales | `files=` más `data=`; el JSON como parte propia `application/json` |
| Traducción rápida de cURL | La tabla anterior, o curlconverter en tu equipo |
| Funciona en cURL, no en Python | Revisa `r.history`, `r.request.headers`, `trust_env` y la versión de HTTP |
| HTTP/2 o llamadas asíncronas | HTTPX; AIOHTTP solo para código asíncrono |

## Preguntas frecuentes

### ¿Hay que definir la cabecera Content-Type al enviar JSON por POST con Requests?

Con `json=`, no: Requests pone él mismo `Content-Type: application/json`. La necesitas cuando pasas texto JSON por `data=`, porque una cadena en `data=` sale sin ella, y entonces una API estricta responde `415 Unsupported Media Type` o `400`.

### ¿Qué diferencia hay entre json= y data=json.dumps() en Requests?

Los dos envían texto JSON, pero solo `json=` añade la cabecera `Content-Type`. Los bytes también pueden ser distintos: `json=` escribe espacios después de los dos puntos y las comas y escapa los caracteres no ASCII. Usa `json=` por defecto, y `data=` con tus propios bytes cuando una API firma el cuerpo.

### ¿Cómo se envía un token Bearer con Python Requests?

Usa `headers={"Authorization": f"Bearer {token}"}`, o defínelo una sola vez en `session.headers`. Lee el token de una variable de entorno. Si un archivo `.netrc` tiene credenciales para el mismo host, Requests usa esas en su lugar; `Session.trust_env = False` lo desactiva.

### ¿Se puede convertir un comando cURL a Python automáticamente?

Sí. curlconverter convierte un comando cURL en código de Requests y se ejecuta en tu propio equipo. Revisa lo que genera: no añade timeout, y Requests sigue redirecciones que el comando cURL no seguía. No pegues comandos con cookies o tokens en conversores en línea.

### ¿Requests sigue las redirecciones? ¿Por qué mi POST se convierte en GET?

Requests sigue las redirecciones con todos los métodos salvo `HEAD`. Tras un `301`, `302` o `303`, un POST pasa a ser un GET y pierde su cuerpo, igual que en los navegadores; tras un `307` o `308`, sigue siendo POST. `allow_redirects=False` se detiene en la primera respuesta.

### ¿Python Requests admite HTTP/2?

No. Requests solo habla HTTP/1.1; en nuestra prueba, `r.raw.version` devolvió `11` en un sitio HTTPS. [HTTPX](https://www.python-httpx.org/http2/) admite HTTP/2 si instalas `httpx[http2]` y creas el cliente con `http2=True`; viene desactivado por defecto.

## En resumen

Para una API JSON, `requests.post(url, json=data, timeout=20)` es todo el trabajo: Requests serializa el cuerpo y pone la cabecera. `data=` envía formularios o bytes exactos, `files=` cuerpos multipart y `params=` la cadena de consulta. Un comando cURL se traduce a estos argumentos opción por opción; si las respuestas siguen siendo distintas, revisa las redirecciones, las cabeceras por defecto, la versión de HTTP y el entorno, en ese orden. Si tienes que llamar a una API desde un país concreto o desde una dirección fija, compara las opciones en nuestra página de [servicios de proxy](/es/proxy).
