Graniite Docs
Quickstarts

Binary file uploads

Three-step signed-URL flow for audio, video, PDF, and image uploads. When and why.

If the file you want to upload lives at a URL (Drive, Dropbox, Twilio, Slack, S3, or anywhere addressable), use POST /api/v1/ingest with kind: "url" in a single call.

If the file only exists as bytes on the client machine, use the three-step signed-URL flow. This page explains why and how.

Why three steps

Vercel, where Graniite runs, caps HTTP request bodies at 4.5 MB. A 50 MB audio file cannot be POSTed through the application API because Vercel rejects the request body before the code runs.

The workaround is the industry-standard direct-to-storage pattern (the same approach used by S3, GCS, R2, and Azure Blob):

1. POST /api/v1/uploads/signed-url    -> server returns a signed Supabase Storage URL
2. PUT  <signed URL>                  -> client uploads bytes directly to Storage (bypasses Vercel)
3. POST /api/v1/uploads/finalize      -> server verifies and kicks off the pipeline

The signed URL is single-use, scoped to one path, and expires in minutes. Direct PUT means the binary never traverses the application servers, which is cheaper and faster.

Step 1: get a signed URL

FieldValue
MethodPOST
URLhttps://graniite.co/api/v1/uploads/signed-url
AuthAuthorization: Bearer lev_…
Body{ "mime": "audio/mpeg", "size": 20485760, "filename": "meeting.mp3" }

size is the file size in bytes. You need to know it ahead of time. mime and filename are used for routing and the storage path.

Response:

{
  "bucket": "media-uploads",
  "path": "<user_id>/<uuid>.mp3",
  "token": "...",
  "signed_url": "https://...supabase.co/...",
  "mime": "audio/mpeg",
  "kind": "audio"
}

Keep signed_url, path, mime, and kind. You will need them.

Step 2: PUT the file to storage

FieldValue
MethodPUT
URLthe signed_url from step 1
Authnone (the signed URL carries its own)
Bodythe raw file bytes
HeadersContent-Type: <mime from step 1>

In n8n, this is the HTTP Request node with Body type: Binary. The Content-Type header must match what you passed to step 1.

Response: 200 OK, empty body.

Step 3: finalize

There are two different endpoints depending on file kind:

kind from step 1Endpoint
audio or videoPOST /api/v1/uploads/finalize
pdf or imagePOST /api/v1/uploads/finalize-document

Both take the same body shape:

{
  "path": "<path from step 1>",
  "mime": "audio/mpeg",
  "size": 20485760,
  "kind": "audio",
  "filename": "meeting.mp3",
  "in_kb": true,
  "auto_transform": false
}

Response:

{
  "status": "transcribing",
  "user_item_id": "uuid",
  "content_id": "uuid",
  "folder_id": "uuid or null"
}

The status semantics match /ingest. transcribing is returned for media. ready is returned for PDFs and images (text extracted synchronously).

Allowed file types

KindEndpoint after finalizeMIMEs and extensions
Audio/finalizemp3, m4a, wav, ogg, flac, aac
Video/finalizemp4, webm, mov
PDF/finalize-documentapplication/pdf
Image/finalize-documentjpg, jpeg, png, webp, gif, heic, heif

The maximum file size is 50 MB per upload. Magic-byte verification runs at finalize time. Sending a .mp3 extension with non-MP3 bytes will fail.

Polling for transcription completion

To wait for the AssemblyAI or Gladia pipeline to finish:

GET /api/v1/items/<user_item_id>
-> { transcription_status: "transcribing" | "done" | "failed", ... }

Poll every 30 seconds. Typical durations are 30 to 90 seconds for short audio and 2 to 5 minutes for long media.

n8n graph

[Trigger fires with binary file in memory]
        |
        v
[HTTP: POST /uploads/signed-url]
        | (extract signed_url, path, mime, kind)
        v
[HTTP: PUT signed_url, body type: Binary]
        |
        v
[IF: kind === "audio" || kind === "video"]
   |                        |
   v                        v
[POST /finalize]    [POST /finalize-document]
        |
        v
[Optional: Wait + GET /api/v1/items/<id> until transcription_status === "done"]

This is five to seven nodes total, and significantly more complex than the URL path. If you can re-upload the file to a publicly addressable URL first (Drive, S3, Dropbox public link), do that and use the URL path instead.

On this page