Official Java SDK for SpicyAPI — image, video and text models behind one API.
One endpoint in front of 83 model families across 121 callable endpoints, billed in USD per request rather than in credits. Media generation is asynchronous and quotable before you spend; text models speak the OpenAI, Anthropic and Gemini wire formats.
<dependency>
<groupId>ai.spicyapi</groupId>
<artifactId>spicyapi-java</artifactId>
<version>0.1.0</version>
</dependency>Requires Java 17+. One dependency: Jackson (com.fasterxml.jackson.core:jackson-databind),
2.12 or newer — that is the release where records became mappable, and the types here are
records. Jackson is declared as a plain compile dependency, not optional and not shaded, so
whichever version your project already has wins.
On the module path the client is ai.spicyapi, pinned in the manifest rather than derived from the
JAR file name.
SpicyClient client = SpicyClient.builder().apiKeyFromEnvironment().build(); // SPICY_API_KEY
CreateTaskRequest request = CreateTaskRequest.of(
"MODEL_ID_FROM_CATALOG", // copy a real id from listModels()
Map.of("prompt", "a lantern in fog"));
TaskQuote quote = client.quote(request); // free; reserves nothing
System.out.println("this will cost at most " + quote.maxCharge());
TaskRecord task = client.run( // createTask + waitForTerminal
request.withQuote(quote), UUID.randomUUID().toString());
task.outputText().ifPresent(System.out::println); // some endpoints answer in text, not files
for (TaskAsset asset : task.assets()) {
asset.url().ifPresent(System.out::println);
}Build the input from that model's own inputSchema — every model has different fields, and
listModels(ModelFilter.all().withIncludeSchema(true)) returns them.
Check outputText() before walking assets(). Transcription and similar endpoints put the
whole result in the text field and return no files at all; a client that only looks for files
reports a perfectly successful task as having produced nothing.
Image-to-video, face swap and image editing need your material on our side first. Upload returns a
spicy:// URI; that is what goes into the input. A publicly reachable HTTPS URL can go in directly
and needs no upload at all.
UploadedFile uploaded = client.uploadFile(Path.of("/path/to/reference.png"));
// uploaded.uri() -> "spicy://f/fil_..."
TaskRecord task = client.run(
CreateTaskRequest.of("MODEL_ID_FROM_CATALOG",
Map.of("image", uploaded.uri(), "prompt", "slow dolly in")),
UUID.randomUUID().toString());uploadFile is the three steps in one call. They are also public, for bytes that are streamed from
somewhere else:
UploadTicket ticket = client.createUploadUrl(UploadContentType.IMAGE_PNG, data.length);
client.putUploadBytes(ticket, data); // goes to object storage, not to the API
UploadedFile file = client.commitUploadedFile(ticket.fileId());Two things about the middle step fail opaquely if you reimplement it. The ticket's headers are part of what the presigned URL signed, so they are forwarded unchanged — drop one, rename one or helpfully add one and storage answers 403. And no SpicyAPI key is attached: the URL is itself the grant, and it points at storage rather than at us.
Images are accepted to 10 MiB, audio and video to 90 MiB. The client refuses an oversized file locally, before the ticket is requested.
Outputs and stored request text are kept for whatever the account is configured for. One task can ask for less:
TaskRecord task = client.run(request, idempotencyKey, Duration.ofHours(1));
task.retention().outputsExpireAt(); // what was actually appliedIt only ever shortens. The service keeps the smallest of your value, the account setting and the
platform maximum, so a request can never buy back a longer window than the account allows.
Duration.ZERO is a real value: the outputs go as soon as the task reaches a terminal state.
The platform's ceiling is not checked here, on purpose. It is the service's number, and a copy
of it in this client would be a constant that goes stale in silence — it would start refusing a
window the platform had already begun accepting. An oversized value is clamped rather than
rejected, which is why retention() on the record, not the number you sent, is what was applied.
retryTask takes no retention of its own: a retry inherits its source task's, so one task family
never ends up with two different expiry times.
Three read-only calls, all free:
Balance balance = client.getBalance(); // account-wide
UsageReport usage = client.getUsage( // this key only
LocalDate.of(2026, 9, 13), LocalDate.of(2026, 9, 20)); // [from, to)
TaskPage page = client.listTasks(TaskFilter.all().withState(TaskState.FAILED));A balance is a budget indicator, not an admission decision. Part of it can be grant money fenced
to particular models, and part can be approved credit rather than cash — balance.fundingSources()
is the breakdown, and an empty modelSlugs on a grant means every model, not none. The exact
answer for one exact request is quote(), which runs the same admission rules and reserves nothing.
Usage is for reconciliation, not for progress. It counts settled charges only, so a period
still running always reads low; late settlement can change a past day; and it carries its own
account-wide rate limit whose limiter fails closed, so polling it can lock every key on the account
out of its own reporting. Follow one task with waitForTerminal instead.
listTasks is scoped to the calling key — sibling keys and console generations are not in it — and
returns metadata only. Walk the pages by moving the cursor and changing nothing else:
TaskFilter filter = TaskFilter.all().withRange(from, to); // pin the dates before walking
TaskPage page = client.listTasks(filter);
while (true) {
page.items().forEach(...);
if (!page.hasMore() || page.nextCursor() == null) break;
page = client.listTasks(filter.withCursor(page.nextCursor()));
}Pinning from and to matters for any walk that can cross midnight UTC. Left unset, the service
recomputes its default window — to is tomorrow in UTC — for every request, so a walk that starts
at 23:59 and continues at 00:01 asks two different questions and splices the answers together.
Nothing reports that: the pages arrive, the loop ends, and the result is quietly wrong.
Set callBackUrl on the request and the terminal record is delivered to you. Verify it before you
believe it:
CallbackSignatureVerifier verifier =
new CallbackSignatureVerifier(System.getenv("SPICY_WEBHOOK_SECRET"));
byte[] body = request.getInputStream().readAllBytes(); // the raw bytes, never re-serialized
WebhookDelivery delivery = verifier.verify(body, request::getHeader);
delivery.task().ifPresent(record -> { /* the same record getTask returns */ });Three ways this goes wrong, all of which the class avoids: re-serializing the body before verifying
(readValue then writeValueAsString changes the bytes, and the digest never matches again),
comparing signatures with equals (it returns on the first differing byte, which is a measurable
timing difference), and skipping the freshness check (a valid signature stays valid forever, so a
captured delivery can be replayed).
Deliveries are retried. Deduplicate on delivery.deliveryId().
The class is named for the scheme it implements: the callBackUrl signature, with its three
X-Webhook-* headers. It is not a general-purpose webhook verifier, and a delivery signed under
some other scheme is rejected here as a mismatch.
Every failure is a SpicyException, which is sealed, so a switch over the taxonomy is exhaustive:
| Type | Means |
|---|---|
SpicyApiException |
the service answered, and the answer was no |
SpicyProtocolException |
something answered, but not with our envelope — a proxy, a captive portal, a wrong base URL |
SpicyTransportException |
no usable answer: DNS, TLS, a dropped connection |
SpicyTimeoutException |
a local request budget elapsed. Outcome unknown, not "did not happen" |
SpicyWaitTimeoutException |
polling gave up. The task is still running and still billable; the exception carries its id |
SpicyUploadException |
object storage refused the presigned PUT |
SpicyWebhookException |
an inbound delivery was not authentic, fresh and well-formed |
Branch on businessCode(), never on the message — messages are prose, they are translated, and
they are not part of the contract. HTTP 503 is shared by three different business codes, so the
status alone cannot tell you what to do:
| Code | Meaning | What to do |
|---|---|---|
40003 |
uploaded bytes do not match their ticket | start over from a fresh ticket; committing again cannot fix it |
40004 |
no deployment serves that parameter combination | change the parameter named in the message |
40201 |
insufficient balance | add funds |
40901 |
the price moved before the task was created | quote again, keep the same idempotency key |
503 |
a dependency is briefly unavailable | back off by Retry-After |
50301 |
no usable deployment or price right now | back off, or pick another model |
50302 |
a synchronous generation failed upstream and was refunded | retry under a new idempotency key; the original one replays the failure |
remediation() returns the same advice at runtime, and describe() puts status, code, request id,
message and advice on one line for a log record. Quote requestId() to support.
Keep one idempotency key per submission. Reuse it for every resend, including after a timeout.
A lost response does not prove the task was not created — a fresh key turns an unknown outcome into
a second paid task. This is also why createTask retries automatically only when you passed a
key: without one, a retry after a timeout would be a second generation and a second charge.
A task that succeeds is charged, even if the result disappoints. quote() reserves nothing and
creates nothing, so quote first when the price matters. createTask holds
estimatedCost and that hold is the ceiling: unused funds come back, and nothing above it is
collected later.
Text models. They speak the OpenAI, Anthropic and Gemini wire formats — the established Java
clients for those already work against https://api.spicyapi.ai/v1 with the same key. Duplicating
them here would just be a worse version of something you can already use.
Client-side applications. Never ship this key inside an Android app, a desktop binary or anything else a user can open. Call from your server.
mvn -B verifyRuns the unit suite against a local com.sun.net.httpserver stub, plus the contract drift check.
That check compares contracts/openapi.yaml against the published contract at
https://docs.spicyapi.ai/openapi.yaml and then re-checks every enum this package hard-codes
against it, because a contract that grows a value without the client growing it does not fail
anywhere — it just rejects the new value locally, silently. It needs no credentials: public
contract, public repository.
Also available in TypeScript · Python · Go · PHP · Java