How HTTP Works

Method · unsafe · not idempotent

> POST /orders HTTP/1.1>  Host: api.example.com>  Content-Type: application/json

HTTP POST Method: Complete Guide with Examples

Learn how the HTTP POST method works. Understand when to use POST requests, request bodies, form submissions, and API calls with practical examples.

Reviewed 6 min readbeginner2 sourcesTry itMarkdown
Safe
No
Idempotent
No
Cacheable
Only with explicit freshness
Request body
Yes
Response body
Yes
Spec
RFC 9110 §9.3.3
On this page

TL;DR: POST sends data in the request body for the server to process: create a record, submit a form, upload a file, or trigger an action. Return 201 Created with a Location header when it creates something. POST isn’t idempotent, so retrying after a dropped connection can create a duplicate, and putting data in the body doesn’t encrypt it. Use HTTPS for that.

What is POST?

RFC 9110 §9.3.3 defines POST loosely: the target resource processes the request body according to its own rules. Creating a record is one common use, but not the definition. A POST can just as well compute and return a result without creating anything.

This curl command sends JSON to an example API (requests and responses on this page are trimmed examples):

curl -i 'https://api.example.com/documents' \
  -H 'Content-Type: application/json' \
  --data '{"title":"Notes","text":"Reviewed"}'

A creation response, headers only:

HTTP/1.1 201 Created
Location: /documents/notes
Content-Type: application/json
Cache-Control: no-store

Location points to the main resource that was created. On a 201 it’s information for the client, not a redirect the browser follows.

Key Characteristics

POST is neither safe nor idempotent. Picture a connection that drops after the server has committed an order but before the response arrives. If the client sends the request again, it may create a second order. Applications can add their own deduplication, but the method doesn’t give you that.

POST responses are cacheable in a narrow case: they need explicit freshness information and a Content-Location that matches the target URI. A cache can then use that stored response for later GET or HEAD requests, never for another POST. In practice, many caches only cache GET and HEAD. If a response must never be stored, say so with Cache-Control: no-store instead of counting on the method to keep it private.

Content Types

For JSON, set Content-Type: application/json. For URL-encoded forms, let curl do the encoding:

curl -i 'https://api.example.com/search' \
  --data-urlencode 'q=red shoes' \
  --data-urlencode 'category=books'

For uploads, point at a real local file and let the client generate the multipart boundary:

curl -i 'https://api.example.com/uploads' \
  -F 'file=@./report.pdf;type=application/pdf' \
  -F 'description=Quarterly report'

In the browser, FormData does the same job. Leave Content-Type unset and let it write the header. A bare multipart/form-data you set by hand has no boundary, so the server can’t parse the body.

The server’s body parser has to match the request’s media type. In Express, JSON and URL-encoded forms each get their own middleware:

app.use(express.json({ limit: '100kb' }))
app.use(express.urlencoded({ extended: false, limit: '100kb' }))

The documented JSON parser limit defaults to 100kb; this example just makes it explicit. Neither middleware handles multipart uploads, so add a multipart parser with its own limits if you accept files. A reverse proxy might reject a large upload before Express sees it, so when you get a 413, find out which layer sent it.

For plain text, --data-binary sends a local file’s contents unchanged:

curl -i 'https://api.example.com/import-text' \
  -H 'Content-Type: text/plain; charset=utf-8' \
  --data-binary @./notes.txt

You could assemble a multipart body by hand, but -F generates matching boundary delimiters and part headers for you. In browser code, FormData does the same.

Real-World Examples

After handling an HTML form, the server can reply with a 303 pointing at a result page. The browser then loads that page with GET, instead of leaving the form POST as the page on screen:

HTTP/1.1 303 See Other
Date: Mon, 05 Oct 2026 12:00:00 GMT
Location: /orders/42
Content-Length: 0

This is the Post/Redirect/Get pattern. Refreshing the result page reloads the GET instead of resubmitting the form. It only helps with refreshes, though: if two submissions already reached the server, you still have two.

Creating a document in Express

This runnable example assigns a UUID, keeps the document in process memory, and returns its URL:

const express = require('express')
const { randomUUID } = require('node:crypto')
const app = express()
const documents = new Map()
app.use(express.json({ limit: '100kb' }))
app.post('/documents', (req, res) => {
  const { title, text } = req.body ?? {}
  if (typeof title !== 'string' || typeof text !== 'string') {
    return res.status(400).json({ error: 'title and text must be strings' })
  }
  const id = randomUUID()
  const document = { id, title, text }
  documents.set(id, document)
  res.location(`/documents/${id}`).status(201).json(document)
})
app.get('/documents/:id', (req, res) => {
  const document = documents.get(req.params.id)
  if (!document) return res.status(404).end()
  res.json(document)
})
app.listen(3000)

Send the same JSON twice and compare the two Location values: you get two documents. That’s POST working as designed. The example skips authentication and durable storage, which a real application needs. The 201 tells the client something was created, and following its Location retrieves it.

Response Status Codes

Use 201 when something was created, 200 to return a processing result, 202 when the work was accepted but isn’t finished, and 204 for success with no body. A content type the endpoint doesn’t support gets 415, and a body that’s too large gets 413.

POST vs GET

GET retrieves a representation; POST submits content for processing. POST URLs can carry query parameters too. Moving values into the body keeps them out of the URL, but application logs and monitoring tools may still record the body. Send credentials over HTTPS and keep their values out of your logs.

Neither method has unlimited size. nginx’s documented default, for example, is client_max_body_size 1m, and a request body over that limit gets 413. Other proxies and applications set their own limits.

POST vs PUT

With POST, the target decides what processing means. PUT creates or replaces the state at a URL the client chooses, and it’s idempotent. POST /documents with a server-assigned URL is a common design, but nothing requires POST to target a collection.

Common Headers

Content-Type describes the body you’re sending, and Accept asks for a response format. Clients normally handle framing headers like Content-Length themselves, and browser Fetch won’t let you set it. Location can point to a new resource. For a cross-origin response, JavaScript can only read it if the server sends Access-Control-Expose-Headers: Location.

Getting POST right

If clients retry after an uncertain failure, give them an idempotency-key contract and document it. The header on its own prevents nothing. The server has to store each key with its request and result, and stop concurrent attempts with the same key from running the action twice.

In browsers, a cross-origin POST with a JSON body or an Authorization header normally triggers a CORS preflight. The actual response needs CORS headers as well, not just the preflight.

JavaScript Examples

const response = await fetch('/api/documents', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ title: 'Notes', text: 'Reviewed' })
})
if (!response.ok) throw new Error(`POST failed: ${response.status}`)
if (response.status !== 204) {
  const result = await response.json() // This endpoint promises JSON when it has content.
  console.log(result)
}

Fetch only rejects on network failures, not HTTP error statuses. Check the status, and know what the endpoint returns, before parsing the body.

Submitting a form with a file

This handler expects an existing <form id="upload"> with a file input named file. Each form field name becomes a multipart part name:

const form = document.querySelector('#upload')
form.addEventListener('submit', async (event) => {
  event.preventDefault()
  const body = new FormData(form)
  const response = await fetch('/api/uploads', { method: 'POST', body })
  if (!response.ok) throw new Error(`Upload failed: ${response.status}`)
  console.log(response.status)
})

For a form without files, URLSearchParams works too:

const body = new URLSearchParams({ q: 'red shoes', category: 'books' })
const response = await fetch('/api/search', { method: 'POST', body })
if (!response.ok) throw new Error(`Search failed: ${response.status}`)

Fetch sets the right form media type for URLSearchParams automatically. With FormData, setting Content-Type yourself can leave the boundary missing or mismatched. Either way, the endpoint has to accept that format. Switching a request from JSON to form encoding changes what the server’s parser receives.

Try It Yourself

Open the create preset. Compare the request Content-Type, the response status and any Location header. It’s an exercise in reading the request and response, not a test of retry deduplication.

If you repeat a creation request, use throwaway example data. Two 201 responses with different Location values mean two resources were created. A 303 redirect to a result page won’t merge them.

Check your understanding

Guided Lab

Create a resource with POST

Use the playground to send a JSON body, then compare the request and response with a GET flow so the creation semantics are concrete.

Practice POST
  1. Open the create preset and inspect the `Content-Type` header and JSON body before sending.
  2. Send the request and note the response status and any Location-style creation hints.
  3. Switch back to a GET preset and compare how the request shape changes when you are only retrieving data.

Look for: POST should include a body and represent a state-changing action, unlike GET.

Why it matters: The fastest way to internalize POST is to contrast a creation request with a read-only request side by side.

Interactive Check

Pick the better POST use case

Which scenario is the strongest fit for POST rather than GET?

Pick the better POST use case

In practice

Express.js
// POST /users — create a new user
app.post('/users', async (req, res) => {
  const { name, email, password } = req.body
  const user = await db.users.create({ name, email, password })
  res.status(201)
    .location(`/users/${user.id}`)
    .json(user)
})

// POST /sessions — login
app.post('/sessions', async (req, res) => {
  const user = await db.users.findByEmail(req.body.email)
  if (!user || !user.verifyPassword(req.body.password)) {
    return res.status(401).json({ error: 'Invalid credentials' })
  }
  const token = generateToken(user)
  res.json({ token })
})
Next.js App Router
// app/api/users/route.ts
export async function POST(request: Request) {
  const body = await request.json()
  const user = await db.users.create(body)
  return Response.json(user, {
    status: 201,
    headers: { Location: `/api/users/${user.id}` }
  })
}

// app/api/sessions/route.ts
export async function POST(request: Request) {
  const { email, password } = await request.json()
  const user = await db.users.findByEmail(email)
  if (!user?.verifyPassword(password)) {
    return Response.json({ error: 'Invalid credentials' }, { status: 401 })
  }
  return Response.json({ token: generateToken(user) })
}
Django
# views.py
import json
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.views.decorators.csrf import csrf_exempt

@csrf_exempt
@require_POST
def create_user(request):
    data = json.loads(request.body)
    user = User.objects.create(
        name=data['name'],
        email=data['email'],
    )
    response = JsonResponse(user.to_dict(), status=201)
    response['Location'] = f'/users/{user.id}'
    return response

Frequently asked questions

What is the HTTP POST method?

POST submits content for the target resource to process under its own rules. It can create a resource, submit a form, or perform an action; it can also return a processing result without creating anything.

When should I use POST instead of GET?

Use POST when submitting content for processing, creating resources, uploading files, or performing actions. Body size limits still apply, and POST responses are not categorically uncacheable.

Is POST secure?

POST does not encrypt submitted data. A body avoids putting values in the URL, but application logs can still capture it. Use HTTPS and avoid logging credentials.

What Content-Type should I use with POST?

Use a media type the endpoint supports, such as application/json or application/x-www-form-urlencoded. For browser FormData or curl multipart uploads, let the client generate the multipart Content-Type with its boundary.

Is POST idempotent?

POST has no general idempotency guarantee. A particular endpoint can implement deduplication or idempotent processing, but clients must follow its documented contract before retrying an uncertain result.

What status code should a POST request return?

Use 201 for creation, 200 for a processing result, 202 for accepted but incomplete work, or 204 for success without content. Location can identify the primary resource created by a 201 response.

Sources

  1. MDN Web Docs: POSTdeveloper.mozilla.org
  2. RFC 9110: POST Methodrfc-editor.org

Keep going

Browse /search