Providers
The three live providers, one section each: Fal, ElevenLabs and Wavespeed.
Fal
Fal is the one provider on the key market that does not take its target in the URL. That single difference accounts for most first-call failures, so it is the first thing in this section.
Billing unit: per megapixel. Fal keeps no record of a call against the seller's account, so nothing you generate here persists on someone else's history.
The target is a header, and the path is empty
The target must be an absolute https URL on one of Fal's two model hosts. Anything else — Fal's storage,
pricing or account hosts, or any other domain — never leaves our fence.
| Host | What it is |
|---|---|
fal.run | Answers with the result itself. Synchronous. |
queue.fal.run | Starts a job and answers with an id. |
fal.runWhat it is · Answers with the result itself. Synchronous.queue.fal.runWhat it is · Starts a job and answers with an id.Using the SDK
@fal-ai/client is the normal way in. Point proxyUrl at our route and send the target always, not just
when the SDK feels like it:
import { fal } from '@fal-ai/client'; fal.config({ // No Fal key here — the seller's key is resolved on our side, at call time. proxyUrl: { url: '$SURPLUS_BASE_URL/resources/fal', when: 'always', }, requestMiddleware: async (request) => ({ ...request, headers: { ...request.headers, Authorization: `Bearer ${process.env.SURPLUS_API_KEY}` }, }), }); const result = await fal.subscribe('fal-ai/flux/schnell', { input: { prompt: 'a tiny red origami crane', image_size: 'square', num_images: 1 }, }); console.log(result.data.images[0].url);
# The Python client has no proxy option, so call the route directly. import os, requests r = requests.post( "$SURPLUS_BASE_URL/resources/fal", headers={ "Authorization": f"Bearer {os.environ['SURPLUS_API_KEY']}", "x-fal-target-url": "https://fal.run/fal-ai/flux/schnell", "Content-Type": "application/json", }, json={"prompt": "a tiny red origami crane", "image_size": "square", "num_images": 1}, timeout=60, ) print(r.headers["x-si-buyer-cost-micro"], r.headers["x-si-list-cost-micro"]) print(r.json()["images"][0]["url"])
when: 'always' matters. With the default the SDK proxies only some calls and sends the rest straight to Fal,
where your request has no credential and fails — with nothing on our side to explain why.
Fal is the one provider whose follow-up URLs we leave untouched: its SDK builds them itself and sends each target in the header, so it stays on your key without our help. Path-style providers get their follow-up URLs rewritten instead — see jobs and settlement.
A worked queue job
curl -X POST $SURPLUS_BASE_URL/resources/fal \ -H "Authorization: Bearer $SURPLUS_API_KEY" \ -H "x-fal-target-url: https://queue.fal.run/fal-ai/flux/dev" \ -H "Content-Type: application/json" \ -d '{"prompt":"a paper boat","image_size":"square"}' # → { "request_id": "764cabcf-…", "status_url": "…", "response_url": "…" }
# Fal's own URLs are left as they are; send them back through the header. curl $SURPLUS_BASE_URL/resources/fal \ -H "Authorization: Bearer $SURPLUS_API_KEY" \ -H "x-fal-target-url: https://queue.fal.run/fal-ai/flux/dev/requests/764cabcf-…/status?logs=1" # → { "status": "IN_QUEUE" | "IN_PROGRESS" | "COMPLETED" }
curl $SURPLUS_BASE_URL/resources/fal \ -H "Authorization: Bearer $SURPLUS_API_KEY" \ -H "x-fal-target-url: https://queue.fal.run/fal-ai/flux/dev/requests/764cabcf-…" # → the images, and this is where the charge settles
COMPLETED does not mean it worked. A failed Fal job also reads COMPLETED, with no error on the status, so
the outcome is only confirmed on the result: 2xx succeeded, 4xx failed. We settle on the same evidence you do.
What is reachable
Only Fal's own public models, on three methods:
| Rule | Covers |
|---|---|
POST /fal-ai/+ | Submit or run a model. |
GET /fal-ai/+ | Status and result. |
PUT /fal-ai/+ | Cancel. |
POST /fal-ai/+Covers · Submit or run a model.GET /fal-ai/+Covers · Status and result.PUT /fal-ai/+Covers · Cancel.fal-ai is the only publisher. That is not a preference — a seller's private apps live under
<their-username>/<app> and answer on these same two hosts to the seller's key, so no denylist could cover
them, while nobody else can deploy under fal-ai. An allowlist of one publisher is the only shape that is
safe by construction. Other public publishers are added as data once verified.
?logs=1 is the one query parameter passed through, because the SDK asks for run logs on status polls.
fal_webhook is deliberately not: it would send your result to a URL we do not control.
Two response headers come back untouched, because the SDK reads both: x-fal-request-id and
x-fal-request-timeout-type.
Two things Fal refuses here.
LLMs
fal-ai/any-llm is Fal's unified LLM endpoint — Claude, GPT, Gemini, Llama — and it is denied provider-wide,
not per listing.
An LLM bills on output tokens, which no quote taken before the call can bound. That makes it a platform
pricing gap rather than a seller's choice, which is exactly why it is not left to the access editor. The rule is
unqualified across every method, not POST-only: a family refused on one verb and open on the others reads as
an oversight the next time someone edits it.
LLM resale is the order book's job, not this market's. Use /v1/chat/completions.
Models that can run for minutes
Fal prices most image models per megapixel or per image, and those answer inside the request. Everything else — video, 3D, long audio — can run for minutes.
- Priced per megapixel or imageRuns on fal.run, answers inline
- Priced any other wayRefused on fal.run — 400 job_route_required
- Submit to queue.fal.runSurvives a replaced task
The refusal names itself: job_route_required. Submit the same model to queue.fal.run instead and follow it
as a job. A queue submit answers immediately either way, so this costs you nothing but a different target host.
On the x402 rail there is no queue option — the message says so, and you need a Surplus API key for those models.
Timeouts
POST /fal-ai/+ is capped at 50 seconds, inside the fleet's 60-second drain, so a deploy never cuts a call
a seller is already billed for. A model that needs longer belongs on the queue.
ElevenLabs
ElevenLabs is a path-style provider: the provider's own path goes straight onto our route, and the seller's
xi-api-key is attached on our side.
Billing unit: per 1 000 characters.
ElevenLabs is the one live provider where the seller's account keeps a record of your call. Their generation history shows that a request happened, even on routes where we delete the result after handing it to you. That is a property of the provider, not of our proxy, and it is why the trait badges on a listing matter here more than anywhere else.
Three routes, and that is the whole surface
ElevenLabs publishes over four hundred operations. Three are reachable.
?output_format= is the one query parameter passed through — it is the only one the three routes take.
Calling them
# Answers with audio BYTES, not JSON. The cost headers still ride on the response. curl -X POST $SURPLUS_BASE_URL/resources/elevenlabs/v1/sound-generation \ -H "Authorization: Bearer $SURPLUS_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"text":"a short soft chime","duration_seconds":1}' \ --output chime.mp3 --dump-header headers.txt grep x-si- headers.txt # x-si-buyer-cost-micro: 1500 # x-si-list-cost-micro: 3000
# Up to the priced ceiling of five minutes; a 5-minute song answers in about 25 s. curl -X POST $SURPLUS_BASE_URL/resources/elevenlabs/v1/music \ -H "Authorization: Bearer $SURPLUS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"prompt":"a slow piano loop in A minor","music_length_ms":30000}' \ --output loop.mp3
# Free and read-only — any valid key may read it, so it doubles as the seller's key probe.
curl $SURPLUS_BASE_URL/resources/elevenlabs/v1/models \
-H "Authorization: Bearer $SURPLUS_API_KEY"POST /v1/music is capped at 50 seconds, inside the fleet's 60-second drain, so a deploy never cuts a
generation the seller is already billed for.
Why the other routes are closed
The rule is one sentence: the seller's account may keep a record of your request, but you may read nothing of that account. Each family below fails it for a different reason, and the reasons are worth knowing because they tell you what would have to change.
| Can they… | Reaches account data | Unpriceable up front | Leaves seller history |
|---|---|---|---|
| Text-to-speech, speech-to-speech, dialogue | Yesvoice_id may be a private cloned voice | No | Yes |
| Speech-to-text | Yesuse_speaker_library, webhook | No | Yes |
| The voice lists (/v1/voices, /v2/voices) | Yesthey ARE the seller’s library | No | No |
| Audio isolation | No | Yesbilled by uploaded audio length | Yes |
| Forced alignment | No | Yessame basis as isolation | Yes |
| Account, keys, billing, usage | Yesservice accounts can mint keys | No | No |
| Streaming | No | Yescannot be metered for settlement | No |
Two of those are worth drawing out:
- Voice routes are not a capacity question. A
voice_idcan name one of the seller's private cloned voices, so serving text-to-speech would need an ownership check we do not have yet. Opening it is that check, not a policy edit. - Audio isolation and forced alignment are priced by the length of a file you upload. No quote taken before the call can bound that, which is the same property that keeps LLMs off Fal. Opening either is a cost-source change first.
Music is the exception that proves the rule: it leaves no history, and its two fields that would touch the
account — finetune_id and store_for_inpainting — are refused at the point where it is priced.
Two layers, not one
A route can be closed two ways here, and the difference is deliberate.
GET /v1/audio-effects/catalog is deliberately not on the denylist. It lists filter presets — a static
catalogue of the same class as GET /v1/models, which is open — so naming it would imply it reaches the
seller's account, which it does not. It stays refused by the allowlist, which is what "we have not opened this
yet" looks like. A denylist that collects everything unopened stops telling you which entries are load-bearing.
Wavespeed
Wavespeed is a path-style provider, and almost everything on it is a job: you submit a task, you get an id, and you collect the result separately.
Billing unit: per image. Wavespeed keeps no record of a call against the seller's account.
What is reachable
Two rules, and together they are the whole surface:
| Rule | Covers |
|---|---|
POST /api/v3/{publisher}/+ | Submit a task to one of 44 allowed publishers' models. |
GET /api/v3/predictions/*/result | Read one task's result. |
POST /api/v3/{publisher}/+Covers · Submit a task to one of 44 allowed publishers' models.GET /api/v3/predictions/*/resultCovers · Read one task's result.Generation only. No price lookups, model lists, uploads, balance, billing or prediction history — and
POST /api/v3/predictions, the generic submit, is refused precisely because it would sidestep the
per-publisher allowlist.
The 44 publishers are the model owners Wavespeed hosts:
alibaba black-forest-labs bria bytedance character-ai chatterbox clarity-ai decart elevenlabs google heygen hitem3d hyper3d ideogram-ai inworld kwaivgi leonardoai lightricks luma meshy meta microsoft midjourney minimax mirelo-ai mureka-ai nvidia openai pika pixverse pruna-ai recraft-ai runwayml skywork-ai sonilo sourceful sync topaz tripo3d veed vidu wavespeed-ai x-ai z-ai
A model under any other owner is refused policy_refused — the publisher is part of the rule, not a hint.
A listed publisher does not mean every model under it is priced. black-forest-labs/flux-schnell reads like
the obvious first call and is a 404 at Wavespeed's own price endpoint, so it is refused provider_unpriceable
rather than charged at a guess. wavespeed-ai/flux-schnell is the priced equivalent. If a model refuses this
way, try the wavespeed-ai mirror of it.
Submit, then collect
- POST the modelAnswers { data: { id, urls } }
- Bound to that offer27 h, your account only
- GET the status URLFree. created → processing → completed
- GET …/resultThe image, and the charge settles
curl -X POST $SURPLUS_BASE_URL/resources/wavespeed/api/v3/wavespeed-ai/flux-schnell \ -H "Authorization: Bearer $SURPLUS_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"prompt":"a tiny blue paper boat on a white table","size":"1024*1024"}'
{
"data": {
"id": "a1b2c3d4e5f6",
"status": "created",
"urls": {
"get": "$SURPLUS_BASE_URL/resources/wavespeed/api/v3/predictions/a1b2c3d4e5f6/result"
}
}
}# Follow the URL the submit gave you — it already points back through us, on your key.
curl $SURPLUS_BASE_URL/resources/wavespeed/api/v3/predictions/a1b2c3d4e5f6/result \
-H "Authorization: Bearer $SURPLUS_API_KEY"Look closely at urls.get in that response. Wavespeed returned its own api.wavespeed.ai URL; we rewrote
it to point back at our route before handing it to you. Follow it as given and your poll stays on your own
Surplus key and on the same seller's offer. Call Wavespeed's URL directly and you have no credential.
That rewriting is why path-style providers need no special handling from you, and it is covered in full under jobs and settlement.
Streaming is refused
Wavespeed publishes streaming variants at /api/v3/{publisher}/{model}/stream, and they are denied
provider-wide rather than left to each seller.
A streamed response has to be metered from the stream, which this rail does not do — it would be billed at the
submit's estimate and settle wrong. That makes it a platform capability gap, not a seller's choice. Both the
/stream and /stream/* forms are denied, because a variant may nest.
Two layers here as well
/api/v3/webhook* is named deliberately: it holds the account's webhook signing secret. It was already
refused by being off the allowlist, but a fence that only works while the allowlist stays narrow is not a fence.
Related
- Your first call — the end-to-end path, all three providers.
- Jobs and settlement — bindings, polling and when you are charged.
- Access rules — how a seller narrows a key further.
- Security and privacy — what reaches a seller's account.
- Provider key endpoints — the endpoint reference.