Hornyhero

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 queuedprocessingsucceeded, 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.
ParameterTypeAcceptedDefaultRequired
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
ResolutionCredits per secondIn 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.
ParameterTypeAcceptedDefaultRequired
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
ResolutionCredits per secondIn 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

ParameterTypeAcceptedDefaultRequired
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
ResolutionCredits per secondIn dollars
720p 60 $0.06 per second
1080p 100 $0.10 per second

z-image

image — text-to-image

ParameterTypeAcceptedDefaultRequired
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
OutputCredits per imageIn 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"
  }
}
CodeHTTPMeaning
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.succeeded deliveries 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 own id and created_atdata.generation is the same object either way — so a handler keyed on data.generation.id and type is unaffected, and one keyed only on X-Hornyhero-Webhook-Id will 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. Not http, and no other scheme.
  • A public host. Loopback, link-local (including the cloud metadata address 169.254.169.254), private ranges such as 10/8, 172.16/12 and 192.168/16, 0.0.0.0, and the .local / .internal families 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/ and https://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]/hook is 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.