Shukr Docs
Shukr Public Media Service

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:

  1. Ask for an upload — you get a pending asset plus presigned instructions.
  2. 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-url201 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_type routes 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

FieldTypeRequiredNotes
filenamestring (1–512)Original filename.
content_type"place" | "post"Business category — not the MIME type.
mime_typestring (1–255)Must be an allowed MIME type.
visibility"public" | "private"Defaults to public.
external_refstring (≤255)Your correlation id (place/post id).
owner_user_idstring (1–255)⚠️Required for service-key callers; ignored for JWT.
stream_provider"bunny" | "onprem" | "cloudflare"For videos; defaults to DEFAULT_STREAM_PROVIDER.
file_size_bytesinteger > 0Enables 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}/complete200 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:

FieldTypeNotes
multipart_partsarrayRequired 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-parts200 OK

What it is — Re-issues presigned PUT URLs for specific multipart parts.

When to use itMultipart 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

FieldTypeNotes
part_numbersinteger[]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-upload200 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).

On this page