API documentation
Generate adult video and images from four models through one request shape, one key and one unit of money.
Getting started
One endpoint creates everything. The base URL is https://api.hornyhero.com, every request and response is JSON, and every model is reached through POST /v1/generations with the same body shape.
curl https://api.hornyhero.com/v1/generations \
-H "Authorization: Bearer $HORNYHERO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "z-image",
"prompt": "a photograph of ...",
"width": 1024,
"height": 1536
}'
An image comes back in roughly fifteen to twenty seconds. Video takes longer, which is why the create call returns as soon as the work is accepted rather than holding your connection for minutes.
Authentication
Send your key as a bearer token on every request:
Authorization: Bearer sk_live_...
Keys are issued per account and can be revoked individually. There are no per-model credentials — the same key reaches every model in this document. A missing, malformed or revoked key returns authentication_error with HTTP 401.
Treat the key as a server-side secret. It spends credits.
Creating a generation
POST /v1/generations takes a model and that model's parameters, flat in the body. Optional metadata is accepted on every model: up to 16 keys, each value at most 512 bytes when serialized, echoed back untouched on the generation object.
Parameters are validated against the model's own definition. A field the model does not declare is rejected by name rather than dropped, so a typo is an error you can see instead of a bill you cannot explain.
curl https://api.hornyhero.com/v1/generations \
-H "Authorization: Bearer $HORNYHERO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7f6a1c02-3f1e-4a8c-9d55-0b2e6b1f9a4d" \
-d '{
"model": "wan-2.7-i2v",
"prompt": "...",
"negative_prompt": "blurry, distorted hands",
"image": "https://your-bucket.example.com/still.png",
"resolution": "1080p",
"duration": 8,
"seed": 42,
"metadata": { "job": "batch-118" }
}'
On success the response is 202 Accepted carrying the generation object with status: "processing":
HTTP/1.1 202 Accepted
{
"id": "gen_01J9Z8K3QW4T7YB2M6PXR5HN0C",
"object": "generation",
"model": "z-image",
"status": "processing",
"created_at": "2026-09-10T12:00:00.000Z",
"started_at": "2026-09-10T12:00:00.412Z",
"completed_at": null,
"output": [],
"error": null,
"usage": { "credits": 40, "duration_seconds": null, "resolution": null },
"metadata": null
}
Credits are reserved at this point, at the quoted price for the parameters you sent. usage.credits reports that reserve while the generation is in flight, and the settled charge once it finishes.
Waiting, or polling
Two ways to get the finished object, and they cost the same.
Ask the request to wait. Send Prefer: wait=N and the create call holds open until the generation reaches a terminal state or the budget runs out. N is in seconds and is clamped to 1–90; a value outside that range is clamped, never rejected. If it finishes in time you get 200 with the complete object, including output. If it does not, you get 202 and the generation keeps running.
curl https://api.hornyhero.com/v1/generations \
-H "Authorization: Bearer $HORNYHERO_API_KEY" \
-H "Content-Type: application/json" \
-H "Prefer: wait=45" \
-d '{"model": "z-image", "prompt": "a photograph of ..."}'
Or poll. Fetch the generation by id until status is terminal — succeeded, failed or canceled. A second or two between polls is plenty; nothing is charged for reading.
curl https://api.hornyhero.com/v1/generations/gen_01J9Z8K3QW4T7YB2M6PXR5HN0C \
-H "Authorization: Bearer $HORNYHERO_API_KEY"
Prefer: wait=N works on the read as well as on the create, so a poll can also block instead of returning immediately.
The gateway is notified by the model tier the moment a generation finishes, and it also polls as a safety net, so a generation is usually terminal in our database within a second of the work completing. That is what makes Prefer: wait and short polling intervals cheap. If you would rather be told than ask, see Webhooks.
To page through recent work, GET /v1/generations returns a list with has_more and next_cursor:
curl "https://api.hornyhero.com/v1/generations?limit=20" \
-H "Authorization: Bearer $HORNYHERO_API_KEY"
The generation object
The same object is returned by create, by read and by list. A finished image generation looks like this:
{
"id": "gen_01J9Z8K3QW4T7YB2M6PXR5HN0C",
"object": "generation",
"model": "z-image",
"status": "succeeded",
"created_at": "2026-09-10T12:00:00.000Z",
"started_at": "2026-09-10T12:00:00.412Z",
"completed_at": "2026-09-10T12:00:17.884Z",
"output": [
{
"type": "image",
"url": "https://api.hornyhero.com/assets/ast_01J9Z8K5.../a1b2c3.png",
"content_type": "image/png",
"width": 1024,
"height": 1536,
"size_bytes": 1874233,
"expires_at": "2026-10-10T12:00:17.884Z"
}
],
"error": null,
"usage": { "credits": 40, "duration_seconds": null, "resolution": null },
"metadata": null
}
status moves queued → processing → succeeded, failed or canceled. output is empty until the generation succeeds, and then holds one entry per asset. error is null unless the generation failed, in which case it carries a code from the table below. usage.credits is the reserve while running and the settled charge afterwards.
Models
Every table below is generated from the definitions this API validates against, so what you read here is what the validator enforces. A parameter absent from a model's table is not accepted by that model. GET /v1/models returns the same information as JSON.
w3-video
video — text-to-video, keyframe, reference
- At least one of prompt, first_frame, last_frame, reference_images must be present.
- first_frame / last_frame, and reference_images / reference_videos / reference_audios cannot be combined in a single request.
| Parameter | Type | Accepted | Default | Required |
|---|---|---|---|---|
| prompt | string | up to 20000 characters | — | — |
| first_frame | string | any length | — | — |
| last_frame | string | any length | — | — |
| reference_images | string[] | up to 10 items | — | — |
| reference_videos | string[] | up to 5 items | — | — |
| reference_audios | string[] | up to 5 items | — | — |
| aspect_ratio | enum | 16:9, 4:3, 1:1, 3:4, 9:16, adaptive | adaptive | — |
| duration | integer | 2–30 | 5 | — |
| audio | boolean | true, false | true | — |
| seed | integer | 0–2147483647, or null | — | — |
| prompt_extend | boolean | true, false | true | — |
| resolution | enum | 480p, 720p, 1080p | 1080p | — |
| Resolution | Credits per second | In dollars |
|---|---|---|
| 480p | 30 | $0.03 per second |
| 720p | 50 | $0.05 per second |
| 1080p | 80 | $0.08 per second |
w3-video-pro
video — text-to-video, keyframe, reference
- At least one of prompt, first_frame, last_frame, reference_images must be present.
- first_frame / last_frame, and reference_images / reference_videos / reference_audios cannot be combined in a single request.
| Parameter | Type | Accepted | Default | Required |
|---|---|---|---|---|
| prompt | string | up to 20000 characters | — | — |
| first_frame | string | any length | — | — |
| last_frame | string | any length | — | — |
| reference_images | string[] | up to 10 items | — | — |
| reference_videos | string[] | up to 5 items | — | — |
| reference_audios | string[] | up to 5 items | — | — |
| aspect_ratio | enum | 16:9, 4:3, 1:1, 3:4, 9:16, adaptive | adaptive | — |
| duration | integer | 2–30 | 5 | — |
| audio | boolean | true, false | true | — |
| seed | integer | 0–2147483647, or null | — | — |
| prompt_extend | boolean | true, false | true | — |
| resolution | enum | 1080p, 2k, 4k | 1080p | — |
| Resolution | Credits per second | In dollars |
|---|---|---|
| 1080p | 120 | $0.12 per second |
| 2k | 200 | $0.20 per second |
| 4k | 350 | $0.35 per second |
wan-2.7-i2v
video — image-to-video
| Parameter | Type | Accepted | Default | Required |
|---|---|---|---|---|
| prompt | string | up to 20000 characters | — | yes |
| negative_prompt | string | up to 20000 characters | — | — |
| image | string | any length | — | yes |
| audio_url | string | any length | — | — |
| resolution | enum | 720p, 1080p | 1080p | — |
| duration | integer | 2–15 | 5 | — |
| audio | boolean | true, false | true | — |
| seed | integer | 0–2147483647, or null | — | — |
| prompt_extend | boolean | true, false | true | — |
| Resolution | Credits per second | In dollars |
|---|---|---|
| 720p | 60 | $0.06 per second |
| 1080p | 100 | $0.10 per second |
z-image
image — text-to-image
| Parameter | Type | Accepted | Default | Required |
|---|---|---|---|---|
| prompt | string | 1–20000 characters | — | yes |
| width | integer | 256–1536 | 1024 | — |
| height | integer | 256–1536 | 1536 | — |
| seed | integer | 0–2147483647, or null | — | — |
| prompt_extend | boolean | true, false | true | — |
| Output | Credits per image | In dollars |
|---|---|---|
| one image | 40 | $0.04 |
Pricing
Everything is priced in credits. One credit is US$0.001, and every credit amount in the system is a whole number. Credits are prepaid; GET /v1/account returns the balance, its dollar equivalent and how many generations are currently in flight.
Video is priced per second, at a rate that depends on the resolution you asked for, with a floor of 2 billable seconds. Images are flat per image — 40 credits, $0.04 — and deliberately do not cost more at a larger size.
Credits are reserved when a generation is accepted and settled when it finishes. The settled charge never exceeds the reserve you were quoted. A generation that fails is refunded in full, and so is one refused by content policy.
Per-model rates are in the tables above.
Idempotency
Send an Idempotency-Key header on a create — any unique string, a UUID is a good choice — and the request becomes safe to retry. The first request with a given key creates the generation. Every later request with that same key returns the original generation object with 200, without dispatching anything and without charging again.
Idempotency-Key: 7f6a1c02-3f1e-4a8c-9d55-0b2e6b1f9a4d
Keys are scoped to your account. A replay is answered from the header alone, so it does not consume rate limit or concurrency budget either — retrying a timed-out request cannot push you into a 429 for work you never repeated.
Errors
Every error is the same shape: an error object with a stable string code, a human message, and param naming the offending field when there is one. Branch on code; the message is for your logs.
HTTP/1.1 400 Bad Request
{
"error": {
"code": "invalid_request",
"message": "duration must be between 2 and 15",
"param": "duration"
}
}
| Code | HTTP | Meaning |
|---|---|---|
invalid_request |
400 | The body, or one parameter in it, is not something the model accepts. |
invalid_input_file |
400 | An image, video or audio input could not be read, or is not permitted. |
authentication_error |
401 | The Authorization header is missing, malformed, or the key is not active. |
insufficient_credits |
402 | The account balance will not cover the reserve for this generation. |
permission_denied |
403 | The key is active but is not allowed to do this. |
not_found |
404 | No such model, generation or asset for this account. |
rate_limit_exceeded |
429 | Too many requests on this key. Back off and retry. |
concurrency_limit |
429 | This account already has the maximum number of generations in flight. |
content_policy_violation |
422 | The prompt or the output was refused by content policy. The reserve is refunded and the attempt is counted. |
provider_error |
502 | The model tier failed or is out of capacity. Nothing was charged. Retry; it is not about your request. |
internal_error |
500 | A fault on our side. Nothing was charged. |
Two of these deserve a note. provider_error means the model tier failed or ran out of capacity — it is never about your request, nothing is charged, and a retry is the right response. content_policy_violation usually arrives on a later poll rather than on the create call, because the refusal happens during generation; the reserve is refunded, and repeated violations suspend the account for review.
Asset delivery
Output is stored by us and served from this domain. Each output entry carries a signed url and the expires_at it stops working, along with content_type, size_bytes and the dimensions or duration where they are known.
Assets are retained for 30 days and then deleted. The URLs are signed and expiring, so treat them as short-lived references rather than permanent links: fetch what you need and store it yourself if you need it longer.
Asset responses support HTTP range requests, so a video can be streamed or seeked without downloading the whole file.
Webhooks
Pass webhook_url on a create and we will POST the finished generation to it. It is per request, not per account: two jobs from the same key can notify two different endpoints, and there is nothing to register beforehand.
curl https://api.hornyhero.com/v1/generations \
-H "Authorization: Bearer $HORNYHERO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "z-image",
"prompt": "a photograph of ...",
"webhook_url": "https://hooks.your-app.example.com/hornyhero"
}'
webhook_url is not a model parameter — it is accepted on every model, alongside metadata, and it does not affect the output or the price.
What arrives
Two event types, generation.succeeded and generation.failed, and the status inside the payload always agrees with the name on the envelope. data.generation is byte-for-byte the object GET /v1/generations/{id} returns — the same serializer produces both — so if you already parse our API responses you need no new type.
POST /hornyhero HTTP/1.1
Content-Type: application/json
X-Hornyhero-Webhook-Id: evt_01K5X8Q9V7ZJH4MC3P2NRT6DBW
X-Hornyhero-Webhook-Timestamp: 1767225600
X-Hornyhero-Webhook-Signature: v1=730840db42d6...
{
"id": "evt_01K5X8Q9V7ZJH4MC3P2NRT6DBW",
"type": "generation.succeeded",
"created_at": "2026-01-01T00:00:00.000Z",
"data": {
"generation": {
"id": "gen_01J9Z8K3QW4T7YB2M6PXR5HN0C",
"object": "generation",
"model": "z-image",
"status": "succeeded",
"created_at": "2026-01-01T00:00:00.000Z",
"started_at": "2026-01-01T00:00:00.412Z",
"completed_at": "2026-01-01T00:00:17.884Z",
"output": [ { "type": "image", "url": "...", "content_type": "image/png", "...": "..." } ],
"error": null,
"usage": { "credits": 40, "duration_seconds": null, "resolution": null },
"metadata": null
}
}
}
Verifying the signature
Every delivery carries three headers. X-Hornyhero-Webhook-Id identifies the event and is the same on every attempt of it. X-Hornyhero-Webhook-Timestamp is Unix seconds. X-Hornyhero-Webhook-Signature is v1= followed by a lowercase hex HMAC-SHA256.
The signed string is the three parts joined by dots:
{webhook-id}.{webhook-timestamp}.{raw-request-body}
The key is your account's webhook secret — the whole whsec_... string as UTF-8 bytes, prefix included. Do not strip the prefix and do not base64-decode what follows it. Read it from GET /v1/account (field webhook_secret); it is stable, and you can read it as often as you like.
Verify against the raw request body. Parsing the JSON and re-serializing it will change key order, whitespace or escaping, and any one of those changes the signature.
const crypto = require('crypto');
// Your account's secret, from GET /v1/account. It never changes.
const SECRET = process.env.HORNYHERO_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
// `rawBody` MUST be the exact bytes of the request body -- a Buffer or the
// raw string. Do not JSON.parse it and re-stringify: key order, whitespace
// and string escaping would all change, and so would the signature.
function verify(rawBody, headers) {
const id = headers['x-hornyhero-webhook-id'];
const timestamp = headers['x-hornyhero-webhook-timestamp'];
const header = headers['x-hornyhero-webhook-signature'];
if (!id || !timestamp || !header) return false;
// Reject anything too far from your clock, in EITHER direction. The
// signature proves who sent the body; only the timestamp bounds when.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', SECRET) // the WHOLE secret string, 'whsec_' included
.update(id + '.' + timestamp + '.')
.update(rawBody)
.digest(); // compare bytes; .digest('hex') works too
const provided = Buffer.from(String(header).split('=').pop(), 'hex');
return (
provided.length === expected.length &&
crypto.timingSafeEqual(provided, expected)
);
}
Re-sign is per attempt: a retry carries a fresh X-Hornyhero-Webhook-Timestamp and a fresh signature over the same body and the same X-Hornyhero-Webhook-Id. That is what lets you enforce a tight clock-skew window — 300 seconds is a good default — without a retry an hour later failing it. Never deduplicate on the timestamp; see Retries below for the key to use.
Test vector
Check your implementation against this before you send us a single request. These four inputs produce exactly this signature; if yours differs, the problem is in your code and not in a delivery you are waiting on. The secret here is a published constant, not anyone's credential.
secret whsec_2b7e151628aed2a6abf7158809cf4f3c2b7e151628aed2a6abf7158809cf4f3c
webhook-id evt_01K5X8Q9V7ZJH4MC3P2NRT6DBW
timestamp 1767225600
body (exactly these bytes, one line, no trailing newline)
{"id":"evt_01K5X8Q9V7ZJH4MC3P2NRT6DBW","type":"generation.succeeded","created_at":"2026-01-01T00:00:00.000Z","data":{"generation":{"id":"gen_01K5X8QB4E9WMT7HD2YS3FNZKP","object":"generation","status":"succeeded"}}}
signed string
evt_01K5X8Q9V7ZJH4MC3P2NRT6DBW.1767225600.<the body above>
expected signature
v1=730840db42d6d965844083378b951879b5dd475bee40f48baa9c272571028839
Retries, and what "delivered" means
Answer with any 2xx and we stop. Anything else — a 4xx, a 5xx, a redirect, a timeout, a DNS or TLS failure — is a failure, and we retry after 15 seconds, 1 minute, 5 minutes, 30 minutes, 1 hour. That is 6 attempts in total; after the last one the delivery is marked exhausted and we stop for good.
We do not follow redirects. A 3xx is recorded as a failure like any other, so publish the final URL rather than one that redirects to it.
Delivery is at least once, and a duplicate can carry a different id. A webhook can arrive more than once for the same generation — a network failure after your handler committed but before its 2xx reached us looks exactly like a failure from here. Make your handler safe to run twice. Ordering is not guaranteed either.
Deduplicate on the pair data.generation.id and type. That pair, not the event id, is what identifies "this generation reached this outcome", and it is the key that is correct in both of the cases below. X-Hornyhero-Webhook-Id (also the envelope's id) is a useful secondary key: it is stable across every attempt of one event, so it collapses retries with a single lookup.
- A retry. Same event, same
X-Hornyhero-Webhook-Id, same body, a fresh timestamp and signature. Either key catches it. - A genuine duplicate event. Two
generation.succeededdeliveries for one generation, with different ids. We poll for a result and our provider also notifies us, and when the two observe the same finish at the same instant both can raise the event. The bodies are identical apart from the envelope's ownidandcreated_at—data.generationis the same object either way — so a handler keyed ondata.generation.idandtypeis unaffected, and one keyed only onX-Hornyhero-Webhook-Idwill process it twice.
Webhooks are a convenience, not the record. The generation itself is durable: if a delivery never succeeds, GET /v1/generations still returns the same object it always would. Anything you cannot afford to miss should reconcile from there.
Acknowledge quickly and do the work afterwards. We give an attempt 10 seconds before abandoning it.
The delivery log
GET /v1/webhook_deliveries returns every attempt we have made for your account, newest first, paginated exactly like GET /v1/generations (limit, cursor, has_more, next_cursor). Filter with generation_id.
curl "https://api.hornyhero.com/v1/webhook_deliveries?generation_id=gen_01J9Z8K3QW4T7YB2M6PXR5HN0C" \
-H "Authorization: Bearer $HORNYHERO_API_KEY"
{
"id": "whd_01K5X8QCV1ZJH4MC3P2NRT6DBX",
"object": "webhook_delivery",
"generation_id": "gen_01J9Z8K3QW4T7YB2M6PXR5HN0C",
"event_id": "evt_01K5X8Q9V7ZJH4MC3P2NRT6DBW",
"event_type": "generation.succeeded",
"url": "https://hooks.your-app.example.com/hornyhero",
"attempt": 1,
"status": "failed",
"response_status": 502,
"error": "<html><head><title>502 Bad Gateway</title>...",
"created_at": "2026-01-01T00:00:18.031Z",
"next_attempt_at": "2026-01-01T00:00:33.031Z",
"delivered_at": null
}
status is delivered, failed (another attempt is due at next_attempt_at) or exhausted. response_status and error are what your endpoint actually returned — the first 512 bytes of the body — which is usually enough to tell a load balancer's 502 from your handler's own stack trace.
Which URLs we accept
We make the request, from our network, so webhook_url is checked when you create the generation and rejected with invalid_request and param: "webhook_url" if it is not all of the following:
https. Nothttp, and no other scheme.- A public host. Loopback, link-local (including the cloud metadata address
169.254.169.254), private ranges such as10/8,172.16/12and192.168/16,0.0.0.0, and the.local/.internalfamilies are all refused — whether you write them as a name or as an address, in any notation. - A host with more than one label.
https://metadata/andhttps://intranet/are refused: a single-label name can only resolve through a resolver's search domains, which is to say only to something inside a network, and no public CA will issue a certificate for one — so a real endpoint is never spelled that way. A trailing dot is fine (https://hooks.example.com./), but the name underneath it still has to pass every rule here. - An IPv6 literal, if you use one, inside global unicast (
2000::/3). IPv6 is an allowlist rather than a blocklist, because its special-purpose ranges are still growing and several of them embed an IPv4 address. Documentation space (2001:db8::/32,3fff::/20), 6to4 (2002::/16) and Teredo (2001:0::/32) are refused from inside that range as well.https://[2606:4700:4700::1111]/hookis accepted; a link-local or unique-local address is not. - No credentials in the URL:
https://user:pass@host/is refused rather than stripped. - At most 2048 characters.
One consequence worth knowing: because we do not follow redirects, an endpoint behind a redirect will never receive anything. Publish the address we should actually POST to.
Rate limits and concurrency
Two ceilings, and both are reported as ordinary errors rather than a dropped connection. Requests are rate limited per key — over the limit returns rate_limit_exceeded with HTTP 429. Generations are also capped per account by how many may be in flight at once; over that returns concurrency_limit, also 429.
GET /v1/account reports generations_in_flight so you can pace yourself rather than discover the cap. Both limits are per account and can be raised — ask.