Upload a File
Use this flow to pay for the transcription of a file on your machine. You pay for the job first, then send the file with a one-use token. If the upload fails, the service refunds you in full. To transcribe a file that already has a public URL, see Create a Job from a URL.
Endpoint
An upload takes two requests to two hosts:
POST https://api.speechischeap.com/v2/x402/jobs/
POST https://upload.speechischeap.com/v2/x402/jobs/
The first request pays for the job and returns a token. The second sends the file with it.
Authentication
Pay for the first request as described under Create a Job from a URL: request a quote, sign it, and send the same request with PAYMENT-SIGNATURE. Replay the request until the answer is READY.
The second request needs no payment and no API key. Send the one-use upload_token from the READY answer in the X-SiC-Upload-Token header:
X-SiC-Upload-Token: YOUR_UPLOAD_TOKEN
Keep the token private. Keep it out of shell arguments, logs, and exported output.
Request
Pay for the Upload
Send a JSON body to https://api.speechischeap.com/v2/x402/jobs/ with input_type set to upload and no input_url. The service takes the processing options, the duration allowance, and the webhook from this request. Multipart fields can't replace them.
| Parameter | Type | Default | Description |
|---|---|---|---|
input_type | string | url | Set to upload (required). |
input_duration | number | - | The length of the file in seconds, from 6 to 86,400 (required). You supply it, and it sets the price and the allowance. See How the Price Is Computed. |
webhook_url | string | "" | The URL that receives the job's outcome and refund updates. See Webhooks. |
| … | … | … | All other parameters from Create a Job from a URL are accepted, except input_url. |
Send the File
Send the file as multipart/form-data to the upload_url from the READY answer:
| Parameter | Type | Description |
|---|---|---|
input_file | file | The audio or video file to transcribe (required), up to 2 GiB. The field must be named input_file. |
The upload host checks the file's contents, not its extension. See Supported File Types and Input Requirements.
Example Requests
Pay for the Upload
Save the request body as request.json:
{
"input_type": "upload",
"input_duration": 200,
"webhook_url": "https://your-domain.com/webhook"
}
Run the example client with a new private state file:
SIC_X402_WALLET_MODULE=./wallet.mjs node x402-client.mjs request.json "$HOME/.local/state/sic/purchase-002.json" 0.01
The client replays a pending payment until the upload is READY. It prints the READY answer without the token and keeps the token in the state file.
Send the File
Read the token from the private state file instead of typing it. This Node.js 22 example uploads audio-file.mp3:
import { openAsBlob } from 'node:fs';
import { readFile } from 'node:fs/promises';
const { result: ready } = JSON.parse(await readFile(`${process.env.HOME}/.local/state/sic/purchase-002.json`, 'utf8'));
const form = new FormData();
form.append('input_file', await openAsBlob('/path/to/audio-file.mp3'), 'audio-file.mp3');
const response = await fetch(ready.upload_url, {
method: 'POST',
headers: { 'X-SiC-Upload-Token': ready.upload_token },
body: form,
redirect: 'error'
});
console.log(await response.text());
With curl, pass the header on standard input so the token never appears in a process list. printf is a shell builtin:
STATE="$HOME/.local/state/sic/purchase-002.json"
UPLOAD_URL=$(jq -r .result.upload_url "$STATE")
UPLOAD_TOKEN=$(jq -r .result.upload_token "$STATE")
printf 'X-SiC-Upload-Token: %s\n' "$UPLOAD_TOKEN" | curl --request POST \
--url "$UPLOAD_URL" \
--header @- \
--form 'input_file=@/path/to/audio-file.mp3' \
--no-buffer
Response
Pay for the Upload
The first request returns the same answers as Create a Job from a URL. Once the payment is confirmed, the 202 is READY:
{
"purchase_id": "00000000-1111-4222-8333-444444444444",
"status": "READY",
"upload_url": "https://upload.speechischeap.com/v2/x402/jobs/",
"upload_token": "<ONE_USE_UPLOAD_TOKEN>",
"admission_expires_at": "2026-09-30T12:05:00.000Z",
"max_input_duration": 300,
"paid_cents": 1
}
max_input_duration is the purchased allowance in seconds. A file longer than that fails, and the service refunds you in full. admission_expires_at is the deadline to start the upload. See Timing.
Send the File
The upload host uses standard HTTP status codes:
| Status Code | Description |
|---|---|
| 200 OK | The upload started. The body is an event stream that ends with the accepted job. |
| 400 Bad Request | The file is missing, misnamed, empty, incomplete, or of an unsupported type. |
| 401 Unauthorized | The X-SiC-Upload-Token header is missing. |
| 404 Not Found | The token is unknown. |
| 409 Conflict | An upload with this token is already in progress. |
| 410 Gone | The token expired, or the upload failed. This is final, and the service refunds the purchase in full. |
| 415 Unsupported Media Type | The request isn't multipart/form-data. |
| 500, 503 | Repeat the same request with the same token. The answer is the accepted job, or a 410 if the purchase failed. |
Errors before the stream starts are JSON with an error field.
After the 200, the upload host sends Server-Sent Events. The stream starts with upload events that show the file transfer. They don't describe the transcription, because x402 jobs have no transcription stream:
data: {"type":"upload","message":"Uploading...","bytes_uploaded":0}
data: {"type":"upload","message":"Uploading...","bytes_uploaded":16384}
The last event is the accepted job:
data: {"status":"PENDING","id":"00000000-1111-4222-8333-555555555555-sic","output":{},"purchase_id":"00000000-1111-4222-8333-444444444444"}
A failure after the stream starts arrives as a final event with status set to error:
data: {"status":"error","error":"Uploaded file exceeds the size limit.","purchase_id":"00000000-1111-4222-8333-444444444444"}
Errors
The errors for the payment request are listed under x402 API errors, and the upload host's errors are listed under upload errors. The most likely are a 401 for a missing token, a 409 while an upload is in progress, and a 410 after a failed or expired upload. A file over 2 GiB fails inside the event stream after the 200, and the purchase is refunded.
Timing
- Start the upload within 300 seconds of payment confirmation.
admission_expires_atis an absolute time, and retrying doesn't extend it. - The deadline covers only the start. An accepted transfer can continue for up to one hour, and transcription can take longer.
- A purchase that isn't used in time expires as
EXPIRED, and the service refunds it in full. - The 300 seconds start when the service confirms the payment, which can happen in the background before you see
READY. Replay a pending purchase at least once a minute until it reportsREADY.
Notes
- Send the file in one request. Once the service accepts your token, any failure ends the purchase with a full refund and the token stops working. That includes an interrupted transfer, a wrong field name, an empty file, and an unsupported file type.
- If the service received the file but you lost the response, repeat the same upload request with the same token. It returns the same job as JSON.
- While a transfer is in progress, a second upload with the same token gets
409, and replaying the payment request reportsCLAIMED. - There's no payment at the upload host. The allowance, options, and webhook come from the payment request.
- The upload host accepts browser requests (CORS), so a browser can send the file with the token. The quote and payment requests to
api.speechischeap.comdon't accept browser requests.