Wire Protocol
The normative HTTP contract between a pushduck client and server — implementable in any language
Scope
pushduck's TypeScript packages are one implementation of a small HTTP contract. This page specifies that contract so any language can implement either half.
The key words MUST, MUST NOT, SHOULD and MAY are used as in RFC 2119.
Version 1. Reported as protocolVersion by the introspection endpoint
and as the X-Pushduck-Protocol header on every response.
Why there is a protocol at all
File bytes never pass through the upload server. The client asks for a
presigned URL, PUTs directly to storage, and tells the server it finished:
Client ──1. presign──► Server ──► Storage (signs)
Client ──2. PUT ─────────────────► Storage (bytes)
Client ──3. complete─► Server (hooks, DB writes)The server is therefore a small, stateless signing and bookkeeping service — which is exactly why it is portable to Go, Python, or anything else.
Transport
- All requests MUST use HTTPS in production.
- Request and response bodies MUST be UTF-8 JSON with
Content-Type: application/json, except errors (see Errors). - A server MUST accept a request body of at least 100 KB on
presign. It MAY reject larger bodies with413.
Endpoint and dispatch
A server exposes one endpoint. Both the route and the operation are carried as query parameters:
POST {endpoint}?route={routeName}&action=presign
POST {endpoint}?route={routeName}&action=complete
GET {endpoint}
POST {endpoint}?route={routeName}&action=multipart-init
POST {endpoint}?route={routeName}&action=multipart-sign
POST {endpoint}?route={routeName}&action=multipart-complete
POST {endpoint}?route={routeName}&action=multipart-abort
POST {endpoint}?route={routeName}&action=multipart-partsrouteMUST be a route name defined by the server. A server MUST answer404for an unknown route.actionMUST be one of the values above. When absent, a server MUST default topresign. An unrecognised action MUST answer400.- Route names SHOULD be percent-encoded by clients.
A conforming server MUST implement presign, complete and introspection.
The five multipart-* actions are OPTIONAL: a server that omits them
MUST answer 400 for each, which a client surfaces as an ordinary failure
rather than misreading as success. See Multipart.
{endpoint} is chosen by the application — /api/upload, /upload,
/v1/files, anything. A server implementation MUST NOT depend on its own
mount path: it receives a request and answers it. This is what allows one
handler to work unchanged across frameworks, and why dispatch is in the query
string rather than the path.
1. Presign
Asks the server to validate a batch and mint upload URLs.
Request — POST {endpoint}?route={routeName}&action=presign
{
"files": [
{ "name": "photo.jpg", "size": 1048576, "type": "image/jpeg" }
],
"metadata": { "albumId": "summer-2026" } // optional, UNTRUSTED
}| Field | Type | Required | Notes |
|---|---|---|---|
files | array | yes | MUST be an array; 400 otherwise |
files[].name | string | yes | Original filename |
files[].size | number | yes | Bytes. A server MUST NOT trust this as the authoritative size |
files[].type | string | yes | MIME type |
metadata | any | no | Client context, passed to middleware |
metadata is untrusted input. A server MUST validate it before use
and MUST NOT accept identity claims (userId, role) from it. Derive
identity from the request's own credentials.
Response — 200
{
"success": true,
"results": [
{
"success": true,
"file": { "name": "photo.jpg", "size": 1048576, "type": "image/jpeg" },
"presignedUrl": "https://bucket.s3.amazonaws.com/…?X-Amz-Signature=…",
"key": "uploads/2026/photo.jpg",
"requiredHeaders": { "Content-Type": "image/jpeg" },
"metadata": { "userId": "u_1" },
"completionToken": "eyJrZXkiOiJ1cGxvYWRzLzIwMjYvcGhvdG8uanBnIiwicm91dGUiOiJpbWFnZVVwbG9hZCJ9.…"
}
]
}results MUST be positionally aligned with the request's files.
| Field | Required | Notes |
|---|---|---|
success | yes | Per-file outcome |
presignedUrl | when success | Absolute URL for the PUT |
key | when success | Object key, echoed back at complete |
requiredHeaders | no | Headers the PUT MUST carry for the signature to validate |
metadata | no | Server middleware output, returned to the client |
completionToken | no | Opaque token binding this key to this route; see Completion tokens |
error | when not success | Human-readable reason |
Per-file versus whole-request failure
This distinction is normative and easy to get wrong:
- A failure describing one file (too large, wrong type, failed validation)
MUST be reported as
results[i].success = falseinside a200. One rejected file MUST NOT fail the others. - A failure describing the request (unauthenticated, forbidden, over quota, unknown route, malformed body) MUST abort the batch and be returned as an error response with the appropriate status.
Burying a 401 inside a 200 makes it invisible to every status check, proxy,
retry policy and alert.
2. Upload
The client PUTs the bytes directly to presignedUrl.
- The client MUST send every header in
requiredHeaders, unmodified. - When
requiredHeadersis absent, the client SHOULD sendContent-Type: {file.type}. - The client MUST NOT send credentials; the URL is already signed.
- This request does not reach the pushduck server.
3. Complete
Tells the server which objects landed, so it can run post-processing.
Request — POST {endpoint}?route={routeName}&action=complete
{
"completions": [
{
"key": "uploads/2026/photo.jpg",
"file": { "name": "photo.jpg", "size": 1048576, "type": "image/jpeg" },
"metadata": { "userId": "u_1" },
"completionToken": "eyJrZXkiOiJ1cGxvYWRzLzIwMjYvcGhvdG8uanBnIiwicm91dGUiOiJpbWFnZVVwbG9hZCJ9.…"
}
]
}This call MUST be authorised. It is where an application attaches a file
to a record, grants access to it, or bills for it, and both key and
metadata arrive from the client. A server MUST run the route's
middleware here exactly as it does at presign, and MUST NOT treat
client metadata as authoritative — the middleware's output is.
A rejection is a whole-request failure with the middleware's status, not a per-file result, and a server MUST authorise every entry before invoking any completion hook, so one unauthorised entry cannot fire the hook for its siblings.
Response — 200
{
"success": true,
"results": [
{
"success": true,
"key": "uploads/2026/photo.jpg",
"url": "https://cdn.example.com/uploads/2026/photo.jpg",
"presignedUrl": "https://…" // optional, time-limited read URL
}
]
}A client SHOULD treat a failed complete as non-fatal: the bytes are
already in storage, and reporting the upload as failed would be inaccurate.
Completion tokens (OPTIONAL)
Middleware authenticates the caller. It does not establish that the caller is
finishing an upload they themselves started, because key travels in the
request body — so an authenticated user can complete against a key belonging to
someone else. Default keys are frequently the sanitised filename, which makes
guessing one straightforward.
A server MAY return a completionToken from presign, binding the issued
key to its route under a signature the client cannot forge. Where it does:
- A client SHOULD echo the token back in the matching completion entry.
- A server MUST verify a token that is present, and MUST answer
403when the token's key or route does not match the completion. - A server MUST accept a completion carrying no token, unless the route is configured to require one. Rejecting by default would break every client older than the token, including — during a rolling deploy — the previous build of the same application.
The token is opaque. Its contents, signing algorithm and secret are entirely the server's business; a client MUST treat it as a string to be echoed.
Multipart (OPTIONAL)
A single PUT cannot carry more than 5 GB, and on a mobile connection a
failure at 90% discards everything transferred. Multipart splits one object
into parts that upload independently, in parallel, each retried on its own.
A server MAY implement it. A server that does not MUST answer 400 to
each multipart-* action, which a client surfaces as an ordinary failure. A
client MUST NOT assume support: it discovers it by attempting init, and
a 400 means fall back to a single PUT or fail the file honestly.
Every multipart action MUST be authorised exactly as presign is. The
route's middleware runs on each call, so a revoked user cannot finish an upload
they started.
Sessions
init returns an opaque session identifying the upload. Later calls
present the session rather than a key and provider upload id.
This is not a convenience. If a client sent { key, uploadId } directly,
anyone who guessed or observed that pair could sign parts for — or abort —
another user's upload. Middleware authenticates the caller; nothing would tie
the caller to the object. A server MUST make the session unforgeable and
MUST derive the key and upload id from it rather than from anything else
the client sent.
4a. multipart-init
// request
{
"file": { "name": "video.mp4", "size": 524288000, "type": "video/mp4" },
"metadata": { "albumId": "summer-2026" }, // optional, UNTRUSTED
"partSize": 5242880 // optional, a preference
}
// response 200
{
"success": true,
"session": "…", // opaque
"key": "uploads/2026/video.mp4",
"partSize": 5242880, // authoritative
"metadata": { "userId": "u_1" }
}The server's partSize is authoritative; a client MUST use it to
compute part boundaries and MUST NOT use its own preference. Route
validation and middleware run here, so a multipart upload cannot bypass a
route's constraints.
4b. multipart-sign
// request
{ "session": "…", "partNumbers": [1, 2, 3] }
// response 200 — a bare array
[
{ "partNumber": 1, "url": "https://…", "size": 5242880 }
]A server MUST reject a part number outside the plan implied by the session's total size and part size, because signing one would authorise a write past the end of the object.
4c. multipart-complete
// request
{
"session": "…",
"parts": [ { "partNumber": 1, "etag": "\"abc…\"" } ],
"file": { "name": "video.mp4", "size": 524288000, "type": "video/mp4" },
"metadata": { }
}
// response 200
{ "success": true, "key": "uploads/2026/video.mp4", "url": "https://…" }- A client MUST send each part's
etagexactly as the storage provider returned it, quotes included. Stripping them is rejected asInvalidPartafter every byte has already been transferred. - A server MUST send parts to the provider in ascending
partNumberorder; providers reject an unordered list.
A browser can only read the ETag response header if the bucket's CORS
policy lists it in ExposeHeaders. Without that, every part uploads
successfully and assembly fails — the single most common multipart
misconfiguration.
4d. multipart-abort
// request
{ "session": "…" }
// response 200
{ "success": true }Discards the session and its parts. Abandoned parts are billed until removed and do not appear in a normal object listing, so a client SHOULD abort on any permanent failure. A server MUST treat aborting an already-absent upload as success, since that is the desired end state.
4e. multipart-parts
// request
{ "session": "…" }
// response 200
{ "success": true, "parts": [ { "partNumber": 1, "etag": "\"abc…\"" } ] }Reports which parts the provider actually holds, which is what makes resume possible: the client's own record is a hint, and the provider is the authority.
A server MUST follow provider pagination to completion. A truncated listing is indistinguishable from a complete one, and stopping early makes a resuming client re-upload parts the provider already has.
Part sizing
Constraints are the intersection of what every supported provider accepts:
| Rule | Value |
|---|---|
| Minimum part size (all but the last) | 5 MiB |
| Maximum part size | 5 GiB |
| Maximum parts per object | 10,000 |
Every part except the last MUST be the same size. That is stricter than S3 requires, and deliberate: Cloudflare R2 rejects an upload whose non-final parts differ in size, so uniform parts are the only sizing that works everywhere.
A server MUST raise the part size when the requested one would exceed the 10,000-part cap. At the 5 MiB floor that cap is reached at roughly 48.8 GiB.
Introspection
Request — GET {endpoint} · Response — 200
{
"success": true,
"protocolVersion": 1,
"routes": [{ "name": "imageUpload", "type": "s3-upload" }]
}Servers MUST implement this. It is how a client, conformance runner, or synthetic check discovers routes and negotiates version.
Advertising optional features
A server MAY include a features array naming the optional parts of the
protocol it implements:
{
"success": true,
"protocolVersion": 1,
"routes": [ { "name": "imageUpload", "type": "s3-upload" } ],
"features": ["multipart"] // optional
}Without it, a client can only discover multipart support by attempting
multipart-init and interpreting a 400 — which is indistinguishable from a
malformed request. A client MUST tolerate the field being absent and
SHOULD treat absence as "unknown" rather than "unsupported", falling back to
attempting the operation.
Defined feature names: multipart.
Errors
Errors MUST be RFC 9457 problem
documents with Content-Type: application/problem+json, and the HTTP status
MUST match the document's status.
{
"type": "https://pushduck.org/errors/file-too-large",
"title": "File exceeds the maximum size",
"status": 413,
"detail": "photo.jpg is 9.0 MB; the limit is 5.0 MB",
"instance": "/api/upload?route=imageUpload&action=presign",
"code": "FILE_TOO_LARGE",
"retryable": false,
"meta": { "limit": 5242880, "actual": 9437184 },
"error": "photo.jpg is 9.0 MB; the limit is 5.0 MB"
}code, retryable, meta and error are RFC 9457 extension members.
Clients MUST branch on code, never on title or detail.
error mirrors detail for clients predating this specification and MAY
be omitted by a new implementation.
Codes
| Code | Status | Retryable | Meaning |
|---|---|---|---|
UNAUTHORIZED | 401 | no | Caller is not authenticated |
FORBIDDEN | 403 | no | Authenticated but not allowed |
NOT_FOUND | 404 | no | Unknown route or object |
BAD_REQUEST | 400 | no | Malformed request |
VALIDATION_FAILED | 400 | no | File failed the route's constraints |
FILE_TOO_LARGE | 413 | no | Exceeds the route's size limit |
FILE_TYPE_NOT_ALLOWED | 415 | no | MIME type not accepted |
TOO_MANY_FILES | 400 | no | Batch exceeds the route's file count |
PAYLOAD_TOO_LARGE | 413 | no | Request body too large |
RATE_LIMITED | 429 | yes | Caller is being throttled |
QUOTA_EXCEEDED | 429 | yes | Quota exhausted |
STORAGE_UNAVAILABLE | 502 | yes | Storage unreachable |
STORAGE_ACCESS_DENIED | 502 | no | Storage rejected our credentials |
NETWORK_ERROR | 502 | yes | Network failure reaching storage |
TIMEOUT | 504 | yes | Operation timed out |
CONFIG_INVALID | 500 | no | Server misconfiguration |
INTERNAL_ERROR | 500 | no | Unhandled failure |
STORAGE_ACCESS_DENIED is 502, not 403: the caller is authorised — the
server's own storage credentials are not. A 403 would wrongly tell the client
to re-authenticate.
An implementation MAY add codes. A client MUST treat an unrecognised
code as INTERNAL_ERROR and fall back to the HTTP status.
Redaction
- For
4xx, a server SHOULD returndetailandmeta— they describe the caller's own request. - For
5xx, a server SHOULD replacedetailwithtitleand omitmetaunless explicitly configured otherwise; they may describe server internals.
Response headers
| Header | Presence | Meaning |
|---|---|---|
X-Pushduck-Protocol | MUST | Protocol version, e.g. 1 |
X-Pushduck-Action | SHOULD | presign, complete, or introspect |
X-Pushduck-Route | SHOULD, when known | Route name |
These exist so infrastructure can distinguish operations. Query strings are legitimately dropped or ignored by CDNs, gateways and APM tools, which would otherwise collapse every upload request into one indistinguishable line.
Versioning
PROTOCOL_VERSIONis bumped only for a breaking change — a new required field, a changed URL shape, a removed operation.- Additive changes (a new optional field, a new
action) MUST NOT bump it. Clients MUST ignore unknown fields. - A server SHOULD accept older protocol versions indefinitely. Deployments are not atomic: a browser holding a cached bundle will speak the old version to a new server for as long as its cache lives.
Added since version 1 shipped
All additive, so none bumped the version. An implementation may omit any of them and still conform; a client MUST tolerate their absence.
| Extension | Where | Notes |
|---|---|---|
X-Pushduck-* response headers | every response | Observability only |
completionToken | presign response, complete request | Binds a key to its route |
multipart-* actions | dispatch | Five actions; 400 when unimplemented |
If the URL shape ever changes — for example moving dispatch into path segments — that is version 2. Servers would accept version 1 permanently, clients would opt in, and the default would flip only at a major release, so no deployment order can break.
Implementing a server in another language
You need three handlers, and a SigV4 presigner from your ecosystem
(aws-sdk-go-v2's presign client, boto3's generate_presigned_url).
// Go — net/http, no dependencies beyond the AWS SDK
func handler(w http.ResponseWriter, r *http.Request) {
route := r.URL.Query().Get("route")
action := r.URL.Query().Get("action")
if action == "" { action = "presign" }
// dispatch on (r.Method, action)
}# Python — FastAPI
@app.post("/api/upload")
async def upload(route: str, action: str = "presign", body: dict = Body(...)):
...Query-string dispatch is deliberate here: r.URL.Query() and
request.query_params are stdlib in both languages and behave identically,
whereas path parameters differ per web framework and would force the server to
know its own mount prefix.
Conformance
An implementation conforms when it:
- Implements
presign,completeand introspection at one endpoint - Reports
protocolVersionand theX-Pushduck-Protocolheader - Returns RFC 9457 problem documents with matching HTTP statuses
- Distinguishes per-file from whole-request failures as specified
- Aligns
resultspositionally with the request'sfiles - Authorises
completewith the route's middleware - Answers
400for any optional action it does not implement - Does not depend on its own mount path
Checking it
That list is prose, and prose is not enough. Point 4 is the rule most often
implemented backwards — a file failing validation is a 200 with
results[].success: false, not a 4xx — and the author of the reference
implementation read it wrong while writing tests for their own server.
So the rules are also an executable suite. It is language-neutral JSON plus a runner, and it tests a server over HTTP without caring what it is written in:
pnpm conformance --url http://localhost:8080/api/uploadThe suite is in conformance/,
along with the fixed route surface an implementation must expose for the
fixtures to assert anything about validation. Each fixture names the section of
this page it comes from, so a failure points at the rule rather than only at
the assertion.
A reference server is included for the ambiguous cases:
pnpm conformance:serve # then diff your responses against itTwo implementations pass it today: the TypeScript server in
packages/pushduck, and the Go server in packages/pushduck-go. Their SigV4
signatures are byte-identical for the same inputs, which is what allows a
client to presign against one and complete against the other.
Adding a rule. Every bug found in any implementation should become a fixture first. A defect found once in one language is then impossible to ship in another — which is the entire reason this suite exists rather than a checklist.