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 pipelineThe 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
| Field | Value |
|---|---|
| Method | POST |
| URL | https://graniite.co/api/v1/uploads/signed-url |
| Auth | Authorization: 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
| Field | Value |
|---|---|
| Method | PUT |
| URL | the signed_url from step 1 |
| Auth | none (the signed URL carries its own) |
| Body | the raw file bytes |
| Headers | Content-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 1 | Endpoint |
|---|---|
audio or video | POST /api/v1/uploads/finalize |
pdf or image | POST /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
| Kind | Endpoint after finalize | MIMEs and extensions |
|---|---|---|
| Audio | /finalize | mp3, m4a, wav, ogg, flac, aac |
| Video | /finalize | mp4, webm, mov |
/finalize-document | application/pdf | |
| Image | /finalize-document | jpg, 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.