Uploads
Create a presigned upload, push the bytes to storage, and complete (or abort) the upload — single, multipart, and TUS flows.
Uploading is a two-step handshake so file bytes never pass through the service:
- Ask for an upload — you get a
pendingasset plus presigned instructions. - Push the bytes directly to storage, then complete the upload.
The response's upload.upload_mode tells you which of the three flows applies. See
Concepts → Upload modes for how the mode is chosen.
All endpoints on this page require authentication (JWT or
service key) and can return 400 / 401 / 403 / 404 / 500.
Create a presigned upload
POST /api/v1/media/upload-url → 201 Created
What it is — Creates a pending MediaAsset
and returns direct-to-storage upload instructions. No bytes flow through the service.
When to use it — The first call whenever another Shukr service needs to store media:
- Places (Maps) — a photo or video of a location →
content_type: "place". - Posts / feedback / notes — an image or file attached to a post, a location's feedback, or a
note about a place →
content_type: "post". - Video — any clip that needs adaptive playback; a
video/*mime_typeroutes the asset to a streaming provider (asset_kind: "stream"). - Generic files — audio, PDFs, or any allowed type that a service wants stored and served via presigned URLs.
How to use it — POST the metadata, read upload.upload_mode, push the bytes with the matching
flow (below), then call /complete.
Scope — A user token owns the new asset; a service key must pass owner_user_id. See
Authentication.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
filename | string (1–512) | ✅ | Original filename. |
content_type | "place" | "post" | ✅ | Business category — not the MIME type. |
mime_type | string (1–255) | ✅ | Must be an allowed MIME type. |
visibility | "public" | "private" | — | Defaults to public. |
external_ref | string (≤255) | — | Your correlation id (place/post id). |
owner_user_id | string (1–255) | ⚠️ | Required for service-key callers; ignored for JWT. |
stream_provider | "bunny" | "onprem" | "cloudflare" | — | For videos; defaults to DEFAULT_STREAM_PROVIDER. |
file_size_bytes | integer > 0 | — | Enables the automatic multipart decision. |
upload_strategy | "auto" | "single" | "multipart" | — | Defaults to auto. Force single/multipart if needed. |
Example — image (single PUT)
curl -X POST https://media.example.com/api/v1/media/upload-url \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"filename": "masjid.jpg",
"content_type": "place",
"mime_type": "image/jpeg",
"external_ref": "place_4821"
}'You must send the exact required_headers (notably Content-Type) on the storage PUT, or
R2 rejects the signature. expires_in is the URL's lifetime in seconds (driven by
R2_SIGNED_URL_EXPIRY, default 3600).
Response shapes by mode
The upload object is a discriminated union on upload_mode:
{
"upload_mode": "single_presigned_put",
"upload_url": "https://<bucket>.r2.cloudflarestorage.com/...?X-Amz-Signature=...",
"expires_in": 3600,
"required_headers": { "Content-Type": "image/jpeg" }
}Complete an upload
POST /api/v1/media/{id}/complete → 200 OK
What it is — Verifies the uploaded object exists and flips the asset pending → uploaded
(setting etag and uploaded_at). For videos it confirms the provider received the upload and
starts/records encoding.
When to use it — Immediately after the client finishes the storage PUT (or the TUS upload).
Until you call it, the asset stays pending and can't be downloaded or played.
How to use it — POST after upload. For multipart, include the ETag of every part. Single and
TUS uploads need no body. Idempotent — a repeat call on an uploaded asset is a no-op.
Scope — Token owner or service key.
Request body
Optional overall. Required only for multipart:
| Field | Type | Notes |
|---|---|---|
multipart_parts | array | Required for multipart. Each item: { part_number: 1–10000, etag: string }. |
curl -X POST https://media.example.com/api/v1/media/550e8400-e29b-41d4-a716-446655440000/complete \
-H "Authorization: Bearer $ACCESS_TOKEN"Refresh multipart part URLs
POST /api/v1/media/{id}/upload-parts → 200 OK
What it is — Re-issues presigned PUT URLs for specific multipart parts.
When to use it — Multipart uploads only. The create call already returns every part URL inline, so reach for this to refresh part URLs that expired during a long upload, or to retry a single failed part.
How to use it — POST the part_numbers you need, PUT the bytes to the returned URLs, and keep
each ETag for /complete.
Scope — Token owner or service key. The asset must still be pending and a multipart
upload.
Request body
| Field | Type | Notes |
|---|---|---|
part_numbers | integer[] | At least one; each 1–10000. |
curl -X POST https://media.example.com/api/v1/media/550e8400-e29b-41d4-a716-446655440000/upload-parts \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "part_numbers": [2, 3] }'Browser-based multipart uploads require R2 bucket CORS to allow PUT from your origin. See
the r2:cors:set script in the media-service app.
Abort a pending upload
POST /api/v1/media/{id}/abort-upload → 200 OK
What it is — Cancels a pending upload and cleans up the R2 multipart session.
When to use it — The user cancels the upload, or it fails and won't be retried, before
/complete. Frees the incomplete multipart pieces.
How to use it — POST with no body. Safe to call defensively: it's a no-op (still 200,
aborted: false) if the asset is already uploaded. The asset stays pending (not deleted).
Scope — Token owner or service key.
curl -X POST https://media.example.com/api/v1/media/550e8400-e29b-41d4-a716-446655440000/abort-upload \
-H "Authorization: Bearer $ACCESS_TOKEN"aborted is false when there was nothing to abort (already uploaded).