spicyapiDocs
Main content

Java SDK

The official Java SDK for SpicyAPI (ai.spicyapi spicyapi-java) — install it, quote and run your first generation, upload a local file, verify callbacks and switch exhaustively over a sealed error taxonomy.

spicyapi-java is the official Java SDK for SpicyAPI. It puts image, video and audio generation behind one SpicyClient: it attaches your API key, maps the response envelope onto records, waits for a task with bounded back-off, uploads local files in one call, verifies callback signatures and turns every failure into a sealed exception type, so a switch over the taxonomy is exhaustive at compile time.

It requires Java 17 or newer and has 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 a plain compile dependency, neither optional nor 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.

Is this the right tool?

You want to…Use
Call SpicyAPI from your own Java or JVM codeThis SDK
Call it from Node.js or TypeScriptThe TypeScript SDK
Call it from Python, Go or PHPThe Python, Go or PHP SDK
Generate things from a terminal without writing codeThe CLI
Let an AI assistant call SpicyAPI for youThe MCP server and the Agent Skill
Use a chat or text modelAn established Java client for the OpenAI, Anthropic or Gemini formats — see Text and streaming

Install it

Add the dependency from Maven Central:

<dependency>
  <groupId>ai.spicyapi</groupId>
  <artifactId>spicyapi-java</artifactId>
  <version>0.1.0</version>
</dependency>

Or, with Gradle (Kotlin DSL):

implementation("ai.spicyapi:spicyapi-java:0.1.0")

Put your key in the environment, so it never appears in your code or in version control:

export SPICY_API_KEY="sk-spicy-..."

Your first result

Ask for the price, decide, create the task, wait, read the output. Quoting is free and reserves nothing. run is createTask followed by waitForTerminal.

SpicyClient client = SpicyClient.builder().apiKeyFromEnvironment().build();  // SPICY_API_KEY

CreateTaskRequest request = CreateTaskRequest.of(
        "MODEL_ID_FROM_CATALOG",
        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);
}

MODEL_ID_FROM_CATALOG is a placeholder. Take a real ID from the authenticated catalogue — client.listModels(), which is GET /api/v1/models — and never from memory or an old page: models are added, paused and retired. Build the input from that model's own inputSchema, which listModels(ModelFilter.all().withIncludeSchema(true)) returns; every model accepts different fields.

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.

Amounts are decimal USD strings, never floats, because binary floats cannot hold most decimal amounts exactly. There are no credits or points anywhere in this API.

Start from a local file

Image-to-video, subject swap and image editing all need your material on our side first. uploadFile does the three steps in one call and returns a spicy:// URI. That URI 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());

The three steps are also public, for bytes 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. See Media.

Keep the result for less time

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 applied

It 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. 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. See Retention.

Balance, usage and history

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 — 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 and returns metadata only. Walk the pages by moving the cursor and changing nothing else, and pin from and to before walking — left unset, the service recomputes its default window (to is tomorrow in UTC) for every request, so a walk that crosses midnight asks two different questions and splices the answers together. Nothing reports that.

Callbacks

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(). See Webhooks.

Errors

Every failure is a SpicyException, which is sealed, so a switch over the taxonomy is exhaustive:

TypeMeans
SpicyApiExceptionthe service answered, and the answer was no
SpicyProtocolExceptionsomething answered, but not with our envelope — a proxy, a captive portal, a wrong base URL
SpicyTransportExceptionno usable answer: DNS, TLS, a dropped connection
SpicyTimeoutExceptiona local request budget elapsed. Outcome unknown, not "did not happen"
SpicyWaitTimeoutExceptionpolling gave up. The task is still running and still billable; the exception carries its ID
SpicyUploadExceptionobject storage refused the presigned PUT
SpicyWebhookExceptionan 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:

CodeMeaningWhat to do
40003uploaded bytes do not match their ticketstart over from a fresh ticket; committing again cannot fix it
40004no deployment serves that parameter combinationchange the parameter named in the message
40201not enough balanceadd funds
40901the price moved before the task was createdquote again, keep the same idempotency key
503a dependency is briefly unavailableback off by Retry-After
50301no usable deployment or price right nowback off, or pick another model
50302a synchronous generation failed upstream and was refundedretry 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() when you contact support. Every code is listed in Errors.

Two things that will save you money

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. See Idempotency.

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.

What this package does not do

  • 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. See Text and streaming.
  • 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.
  • Choosing a model or filling in its fields. It hands you the catalogue and each model's schema; you decide what to send.
  • Cancelling a task. Once a task is accepted it runs to the end.

Source

The artifact is MIT-licensed and developed in the open at github.com/spicyapi-ai/spicy-java. mvn -B verify runs the unit suite against a local stub plus a contract drift check that compares the vendored contract against the published one at https://docs.spicyapi.ai/openapi.yaml, then re-checks every enum the package hard-codes — because a contract that grows a value without the client growing it does not fail anywhere, it just rejects the new value locally, silently.

Next steps

On this page