PHP SDK
The official PHP SDK for SpicyAPI (spicyapi/spicyapi) — install it with Composer, quote and run your first generation, upload a local file, verify webhooks safely and handle every error code.
spicyapi/spicyapi is the official PHP SDK for SpicyAPI. It puts image, video and audio generation behind one SpicyClient: it attaches your API key, unwraps the response envelope into plain associative arrays, waits for a task with bounded back-off, uploads local files in one call and verifies webhook signatures without any of the three mistakes PHP code usually makes there.
It requires PHP 8.1 or newer plus ext-json and ext-hash, and has no library dependencies. ext-curl is used when present; without it the client falls back to the https:// stream wrapper.
Is this the right tool?
| You want to… | Use |
|---|---|
| Call SpicyAPI from your own PHP code | This SDK |
| Call it from Node.js or TypeScript | The TypeScript SDK |
| Call it from Python, Go or Java | The Python, Go or Java SDK |
| Generate things from a terminal without writing code | The CLI |
| Let an AI assistant call SpicyAPI for you | The MCP server and the Agent Skill |
| Use a chat or text model | An established PHP client for the OpenAI, Anthropic or Gemini formats — see Text and streaming |
Install it
composer require spicyapi/spicyapiPut 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 files. Quoting is free and reserves nothing.
use SpicyApi\SpicyClient;
$client = new SpicyClient(); // reads SPICY_API_KEY from the environment
$quote = $client->quote('MODEL_ID_FROM_CATALOG', ['prompt' => 'a lantern in fog']);
printf("at most %s\n", $quote['maxCharge']); // decide before you spend
$task = $client->createTask(
model: $quote['model'],
input: ['prompt' => 'a lantern in fog'],
idempotencyKey: SpicyClient::idempotencyKey(),
quoteId: $quote['quoteId'],
expectedCost: $quote['estimatedCost'],
);
$final = $client->waitForTerminal($task['taskId']);
foreach (SpicyClient::readyAssets($final) as $asset) {
echo $asset['url'], "\n";
}Every call returns the decoded data of the response envelope as a plain associative array, so read it with $quote['maxCharge'], not $quote->maxCharge.
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() returns; every model accepts different fields.
Amounts are decimal USD strings, never floats, because binary floats cannot hold most decimal amounts exactly. Compare them with SpicyClient::compareUsd(). There are no credits or points anywhere in this API.
waitForTerminal returns on any terminal state, and a terminal state is not a successful one. Check the state before reading output, and read SpicyClient::outputText($final) first for endpoints that answer in text rather than files.
Skip the first poll
waitSeconds holds the connection open until the task finishes, so a short job needs no polling loop at all. It does not promise a finished task. If the budget runs out you get the ordinary accepted response instead — same shape, different state — so branch on the state, never on the fact that you passed the parameter:
$submitted = $client->createTask(
model: 'MODEL_ID_FROM_CATALOG',
input: ['prompt' => 'a lantern in fog'],
idempotencyKey: SpicyClient::idempotencyKey(),
waitSeconds: 30,
);
if (!SpicyClient::isTerminal($submitted)) {
$submitted = $client->waitForTerminal($submitted['taskId']);
}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-step upload — ticket, PUT to storage, commit — in one call and returns a spicy:// URI. That URI is what goes into the input.
$uploaded = $client->uploadFile('/path/to/reference.png');
$task = $client->createTask(
model: 'MODEL_ID_FROM_CATALOG',
input: ['image' => $uploaded['uri'], 'prompt' => 'slow dolly in'],
idempotencyKey: SpicyClient::idempotencyKey(),
);A publicly reachable HTTPS URL can go into the input directly and needs no upload at all. Images are accepted to 10 MiB, audio and video to 90 MiB; see Media.
Webhooks
use SpicyApi\SpicyWebhook;
// The secret comes first. The other arguments are optional: $_SERVER and the raw bytes
// of php://input are read for you unless a framework already consumed them.
$delivery = SpicyWebhook::verifyRequest(getenv('SPICY_WEBHOOK_SECRET') ?: '');
// Inside a framework that buffers the body, hand it the exact bytes yourself:
$delivery = SpicyWebhook::verifyRequest(
secret: getenv('SPICY_WEBHOOK_SECRET') ?: '',
server: $_SERVER,
rawBody: $request->getContent(),
);Three ways PHP code gets this wrong, all of which this class avoids:
- Re-serialising the body before verifying.
json_decodethenjson_encodechanges the bytes, and the signature will never match. Verify the raw bytes. - Comparing signatures with
==or===. Beyond timing,==on two strings that both look like0e…compares them as numbers —"0e123" == "0e456"istrue. This useshash_equals. - Skipping the timestamp check. A valid signature stays valid forever; without a freshness window, a captured delivery can be replayed.
Deliveries are retried, so deduplicate on the delivery ID. Full delivery format and retry schedule are in Webhooks.
Balance, usage and history
$balance = $client->getBalance(); // available / held / total, USD strings
$usage = $client->getUsage('2026-09-01', '2026-09-08'); // settled spend over [from, to)
foreach ($client->iterTasks(state: 'succeeded') as $summary) {
echo $summary['taskId'], "\n"; // every page, filters repeated for you
}held is money reserved by tasks that have not settled, so it is neither spent nor available. Dates are UTC calendar days, YYYY-MM-DD, forming a half-open [from, to) interval of at most 92 days. All three calls see only the calling API key: sibling keys and console generations are not included.
getUsage() is for reconciliation, not for progress. It carries an account-wide rate budget shared by every key, and that limiter fails closed — polling it can lock the whole account out of its own reporting. Follow a task with getTask() instead.
listTasks() returns one page as {items, hasMore, nextCursor}. Paginating by hand means repeating every filter unchanged and moving only the cursor, and stopping when hasMore is false or nextCursor is empty — checking only hasMore turns a server-side glitch into an endless loop of paid calls that looks like a hang. iterTasks() does all of that.
Errors
Failures throw SpicyApiError carrying $businessCode, $requestId and $retryAfterSeconds. Branch on the business code, never on the message — messages are translated, codes are not.
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 |
40004 | no deployment serves that parameter combination | change the parameter named in the message |
40201 | not enough balance | add funds |
409 | the idempotency key was reused for a different request | resend the original request under that key, or the changed one under a new key |
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 | refresh the catalogue |
50302 | a synchronous generation failed upstream and was refunded | retry with a new idempotency key |
$error->guidance() returns the same advice at runtime. Every code is listed in Errors.
Two things that will save you money
Keep one idempotency key per submission, and reuse it for every resend — a lost response does not prove the task was not created. See Idempotency.
A task that succeeds is charged, even if the result disappoints. quote() reserves nothing and creates nothing.
What this package does not do
- Text models. They speak the OpenAI, Anthropic and Gemini wire formats; the established PHP clients for those already work against
https://api.spicyapi.ai/v1. See Text and streaming. - Browser-side code. The key belongs on your server, never in anything you ship to a visitor.
- 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 package is MIT-licensed and developed in the open at github.com/spicyapi-ai/spicy-php. Its README is the shortest complete tour of the client, and examples/ holds runnable scripts; this page adds the surrounding concepts.
Next steps
Model catalog and schemas
How to read a model's inputSchema and choose valid inputs
Asynchronous tasks
Task states, polling and what each field means
Quotes and compatibility
See the exact price of a request before you pay it
Webhooks
Delivery format, retry schedule and signature verification
Errors
Every error code and which ones are worth retrying
Java SDK
The same workflow from Java, with a sealed error taxonomy

