Jetski Bay Content API

Overview

The Content API turns a written brief and whatever source material you hand it into a finished, published piece of communication: a blog post, a one-page site, or a presentation deck. It is the public front door to the generation pipeline Jetski Bay runs internally — the same code path, reached with an API key instead of a browser session.

The base URL is https://content.jetskibay.com. Every request and every response is application/json, UTF-8. The version lives in the path: everything below is under /v1.

Nothing here is synchronous. Generating a piece takes minutes, so the three create endpoints return 202 with a job to poll rather than holding a connection open. There is no blocking variant to reach for, and that is deliberate — an open socket for four minutes is a request you were going to lose.

Responses are additive: a future release may add fields, and a well-behaved client ignores the ones it does not recognise. GET /v1 is the authoritative description of what this deployment currently supports — if this page and that response ever disagree, believe the API.

Authentication

Every metered endpoint takes a key, in either of two headers. Pick one; they are equivalent.

headers
x-api-key: jbk_7c41f0a9d2e8b5163a0f4c7e

# …or, if your client prefers a standard scheme
Authorization: Bearer jbk_7c41f0a9d2e8b5163a0f4c7e

A request with no usable credential is rejected with 401 and carries the challenge header www-authenticate: Bearer realm="Jetski Bay Content API". Three endpoints need no key at all — GET /v1, GET /v1/variants and GET /v1/skills?variant=… — so you can build a picker in your own UI before anyone has signed up. /v1/skills is the one of the three that will not answer bare: its variant query parameter is required.

Every path here serves a fixed set of methods — the three create endpoints are POST only, everything else is GET, plus DELETE on /v1/keys/current. Anything else on a path that does exist is 405 method_not_allowed, not a 404. And a deployment with no key-signing secret configured answers 503 key_secret_unavailable on every keyed route before it looks at your credential at all — that one is ours to fix, not yours.

Keys

Keys are minted per email address and mailed to it. A trial key is good for 10 requests and expires 24 hours after it is issued. Only a hash of the key is stored, so the email is the one place the key itself exists: if you lose it, mint another.

No key yet?

POST/v1/keys

Mints a key for an email address and mails it. No authentication — this is how you get authenticated in the first place. The response never contains the key.

request
curl -X POST https://content.jetskibay.com/v1/keys \
  -H "content-type: application/json" \
  -d '{"email":"you@company.com"}'
202 accepted
{
  "status": "sent",
  "message": "Your API key is on its way to you@company.com.",
  "key": {
    "keyPrefix": "jbk_7c41f0a…",
    "tier": "trial",
    "requestLimit": 10,
    "requestsRemaining": 10,
    "expiresAt": "2026-09-06T09:12:44.000Z"
  }
}

Re-minting carries your allowance over and kills the old key. Ask for a key at an address that already has a live one and you get a fresh key with whatever requests were left on the old one — and the old one stops working immediately. Losing a key costs you nothing, and asking for a second one while the first is still alive buys you nothing — the arithmetic is the same either way. What bounds this in general is the mint cap: a limited number of keys per address, and per network, per day.

Your work belongs to the address, not to the key. A job — and any entity that job produced — is readable by any key minted to the same email address, not only by the exact key that started it. That is deliberate rather than sloppy: re-minting is the documented way to recover a lost key, and if ownership were bound to the key id, recovering would silently orphan every job in flight and every page you had already made. Same address, same work.

A key minted to a different address gets 404 on your job ids, never 403 — a 403 would confirm the id exists, which is exactly what a stranger should not learn. Nothing here widens anything: a key is only ever delivered to the address it was minted for, so holding the mailbox is already what holding the key proves.

The job contract

All three create endpoints behave identically. You POST a brief, you get 202 immediately, and you poll the URL it hands you until the job reaches a terminal state.

202 accepted · every create endpoint
{
  "requestId": "9c2f7a41-6d0b-4f18-9a72-1b3e5c8d40aa",
  "status": "queued",
  "variant": "blog",
  "statusUrl": "https://content.jetskibay.com/v1/jobs/9c2f7a41-…",
  "pollAfterMs": 4000,
  "contextRef": "contexts/4f0b…c71a/manifest.json"
}

Wait pollAfterMs milliseconds, then GET the statusUrl. Keep waiting and polling while status is queued or running. Do not invent your own interval that is shorter than the one you were given — the number is there so we can slow you down without breaking you.

Job states
StatusTerminalMeans
queuednoAccepted and waiting for a worker.
runningnoIn progress. The phase field says where it is.
succeededyesFinished. The result is on the job.
failedyesDid not finish. A plain-language reason is on the job.
cancelledyesStopped before it finished.
unknownnoThe pipeline reported a state this version of the API does not recognise. It is surfaced as unknown rather than coerced into one of the five above, because a wrong guess would be worse than an honest one. Keep polling.

The first five are the whole set. Treat anything else — including unknown — as "still working" and keep polling until the response says otherwise.

A terminal succeeded carries a result object: entityId always, liveUrl for a blog post or a page, downloadUrl for a deck. A terminal failed or cancelled carries error instead. Only a non-terminal response carries pollAfterMs, so its absence is itself a signal that the job has stopped moving.

A create also honours an idempotency-key request header — one to 180 visible characters. Send the same key twice with the same body and the prepared context is reused rather than rebuilt, and the key travels on to the pipeline so a retry does not fan out into parallel work. Send the same key with a different body and the request is refused with 409 invalid_request. Omit it and one is generated for you, which means a blind retry is a fresh run. Note that the header does not make a retry free: quota is charged per request, before any of this is looked at.

Ownership of what you made is recorded when you poll the finished job. A create does not know its own id until the pipeline picks one — a blog slug, a page id, a deck's durable name — so the ledger binds that id to you the first time you read the job in a terminal state.

The consequence is worth planning for rather than discovering: fire a create, never poll it to succeeded, and you will not be able to update or rewrite that entity later, or to download that deck. Both answer 404, because as far as the ledger is concerned nobody has finished making that thing. Poll every job you start through to a terminal state, even when you do not need the result at that moment.

A running job also reports the pipeline phase it is in, in this order: prepare, source, generate, validate, commit, publish. Phases are for showing a human what is happening; branch on status, not on phase.

Service index

GET/v1

Unauthenticated. Reports the API version, the variant list, the current trial terms, the list of endpoints this deployment serves, and the branding state of each generator. If you are writing a client, read this rather than hardcoding what this page says.

200 ok
{
  "service": "jetskibay-content",
  "version": "v1",
  "schemaVersion": 1,
  "documentation": "https://content.jetskibay.com/docs",
  "variants": ["blog", "page", "deck"],
  "trial": {
    "requestLimit": 10,
    "windowHours": 24,
    "deliver": "email"
  },
  "endpoints": {
    "index": "/v1",
    "variants": "/v1/variants",
    "skills": "/v1/skills",
    "keys": "/v1/keys",
    "keyStatus": "/v1/keys/current",
    "posts": "/v1/posts",
    "pages": "/v1/pages",
    "decks": "/v1/decks",
    "jobs": "/v1/jobs/{requestId}",
    "deckDownload": "/v1/decks/{id}/download"
  },
  "brandSource": {
    "blog": "live",
    "page": "live",
    "deck": "unreported"
  }
}

brandSource

Each generator resolves the Jetski Bay brand live at run time and falls back to a compiled-in snapshot when that fetch fails. Somebody working in the Show UI can see a degraded brand on the page in front of them. You cannot: you get a finished artifact with no way to tell which branding went into it. That asymmetry is the only reason this field is here.

brandSource values
ValueMeans
liveThat service resolved branding from the live source on its last check.
snapshotThe live fetch failed and the compiled-in snapshot is in use. Output is still produced; it may be visually behind.
unreportedThe service answered but does not publish a branding state. deck currently reads this always — it is not a degradation, it is an absence of a signal.
unavailableThe health probe itself failed or timed out. Says nothing about branding, only that we could not ask.

The whole index response is cached for 60 seconds, because it takes no key and three health probes per anonymous hit would be an amplifier rather than a feature. So brandSource is up to a minute stale, and it is not a monitoring endpoint — status.jetskibay.com is.

Variants

GET/v1/variants

Unauthenticated. Returns the three variants — blog, page, deck — with the modes each one accepts, the options each one honours, where its output lands, and the endpoint each one is posted to. This is the honest source for a variant picker: if we add options or change a mode, they show up here first.

200 ok
{
  "variants": [
    {
      "id": "deck",
      "label": "Presentation deck",
      "endpoint": "/v1/decks",
      "modes": ["create", "rewrite"],
      "output": {
        "kind": "download",
        "downloadEndpoint": "/v1/decks/{id}/download",
        "liveUrl": false
      },
      "options": {
        "targetSlides": { "type": "integer", "minimum": 3, "maximum": 30 },
        "aspectRatio": { "enum": ["16:9", "4:3"] },
        "speakerNotes": { "type": "boolean" },
        "audience": { "enum": ["general", "leadership", "project-team", "external"] },
        "tone": { "enum": ["insightful", "direct", "optimistic"] },
        "goal": { "enum": ["inform", "align", "persuade", "launch"] },
        "density": { "enum": ["airy", "balanced", "detailed"] }
      }
    }
  ]
}

output.kind is "published" with liveUrl: true for blog and page, and "download" with liveUrl: false for deck. Branch on that rather than hardcoding the one exception.

Modes are per variant

There is no universal set of modes. page takes create and update; blog and deck take create and rewrite. Sending a mode a variant does not accept is a 400 invalid_request naming the variant — not a silent downgrade to create.

Modes by variant
VariantModesNeeds an entityId
blog create · rewrite For rewrite.
page create · update For update.
deck create · rewrite For rewrite.

The modes array on this endpoint is the authoritative list — the table above is a convenience, and if the two ever disagree, believe the API. mode defaults to create when you omit it. update and rewrite both require an entityId, and that id must be one a finished job of yours produced.

Skills

GET/v1/skills?variant=blog

Unauthenticated. A skill is a named behaviour the pipeline can switch on for a run — a house style, a research habit, a structural pattern. Pass variant to get the catalogue that applies to that variant, then send the ids you want as skillIds on the request body.

Every variant already runs three skills you did not ask for — the ones marked selected_by_default, which are also the ones marked required. Those count against the ceiling of five skills per run, so in practice you are adding at most two. Ask for a sixth, or for an id that is not in this variant's catalogue, and the request comes back 400 bad_request saying which.

?variant= is required. Call GET /v1/skills with no variant, or with anything other than blog, page or deck, and you get 400 invalid_variant. There is deliberately no default: a skill catalogue for the wrong variant is a list of ids the pipeline will refuse, which would surface as a far more confusing 400 one request later.

200 ok
{
  "variant": "blog",
  "skills": [
    {
      "id": "…",
      "label": "…",
      "description": "What this skill changes about the run.",
      "variants": ["blog"],
      "required": false,
      "selected_by_default": false,
      "version": "…"
    }
  ]
}

Skill ids are not stable literals in this document on purpose — the catalogue moves. Read it at request time and send back ids you got from it.

Create

Three endpoints, one body. The variant is the path you post to, so you do not send a variant field.

Create endpoints
EndpointMakesModesNotes
POST /v1/posts A blog post create · rewrite Published to the blog with its own slug. rewrite starts the text over on a post you already made, and needs its entityId.
POST /v1/pages A one-pager create · update Send an existing entityId with mode: "update" to revise that page in place instead of creating a second one. A page does not take rewrite.
POST /v1/decks A presentation create · rewrite Delivered as a downloadable artifact, not a public URL. See where output lands. A deck does not take update.

A contradicting variant is refused, not overridden. Leave the field out, or set it to the variant the path already names. Post {"variant":"blog"} to /v1/decks and you get 400 variant_conflict, because a client sending that is a client that thinks it is posting something else — and silently honouring the path is how you end up billed for a deck you asked to be a blog post.

All three answer 202 with the job envelope described in the job contract. All three charge one request against your key at the moment work starts.

curl -X POST https://content.jetskibay.com/v1/posts \
  -H "x-api-key: $JETSKI_KEY" \
  -H "content-type: application/json" \
  -d '{
    "mode": "create",
    "brief": "How we cut cold-start latency on the fleet. Lead with the
              measurement, not the fix. Engineers, not executives.",
    "research": { "webSearch": true },
    "options": {
      "targetWords": 900,
      "audience": "project-team",
      "tone": "direct",
      "goal": "inform"
    }
  }'

The page example says "mode": "update" because that is the follow-up mode a page has. The same shape against /v1/posts or /v1/decks would say "mode": "rewrite" instead — swapping the two is a 400, and one you have already paid for. See modes.

Poll a job

GET/v1/jobs/{requestId}

Free — polling never consumes quota. A job is readable by any key minted to the address that started it, which is not the same thing as the one key that started it: see keys for why re-minting has to keep working. A key belonging to a different address gets 404, not 403, because whether that job exists is none of its business.

200 · still working
{
  "requestId": "9c2f7a41-…",
  "variant": "blog",
  "status": "running",
  "phase": "generate",
  "createdAt": "2026-09-05T09:12:44.000Z",
  "updatedAt": "2026-09-05T09:14:02.000Z",
  "pollAfterMs": 4000
}
200 · succeeded (blog)
{
  "requestId": "9c2f7a41-…",
  "variant": "blog",
  "status": "succeeded",
  "phase": "publish",
  "result": {
    "entityId": "how-we-cut-cold-start-latency",
    "liveUrl": "https://blog.jetskibay.com/posts/how-we-cut-cold-start-latency"
  }
}
200 · succeeded (deck)
{
  "requestId": "3b70e2c9-…",
  "variant": "deck",
  "status": "succeeded",
  "phase": "publish",
  "result": {
    "entityId": "dk_1a9f0c",
    "downloadUrl": "https://content.jetskibay.com/v1/decks/dk_1a9f0c/download"
  }
}

A deck carries downloadUrl and no liveUrl; a blog post or a page carries liveUrl and no downloadUrl. Neither key is invented for the other variant, so result.liveUrl being absent on a deck is the contract, not a missing field.

200 · failed
{
  "requestId": "9c2f7a41-…",
  "status": "failed",
  "error": "The brief did not contain enough to write about."
}

A failed job is still an HTTP 200. The request to read the job succeeded; the job is what failed. Branch on the status field, not on the status code, or you will loop forever on a dead job.

Download a deck

GET/v1/decks/{id}/download

Returns the deck artifact for a finished deck job, authenticated with any key minted to the address that created it. This is the only way to get a deck out of the API — see where output lands for why. Free: the request that made the deck was already charged, and charging again for fetching the thing you paid for would make a retried download cost real money.

The {id} must be an entity one of your finished jobs produced — which, as the job contract says, means a job you polled through to succeeded. Anything else is 404: an id you never made, an id you made but never polled, and someone else's id are deliberately indistinguishable from the outside.

request
curl -L https://content.jetskibay.com/v1/decks/dk_1a9f0c/download \
  -H "x-api-key: $JETSKI_KEY" \
  -o deck.html

Key status

GET/v1/keys/current

Reports the allowance and expiry of the key on the request. Free — checking how much you have left does not spend any of it. Useful as a cheap credential check before you commit to a run.

200 ok
{
  "key": {
    "keyPrefix": "jbk_7c41f0a…",
    "tier": "trial",
    "requestLimit": 10,
    "requestCount": 3,
    "requestsRemaining": 7,
    "createdAt": "2026-09-05T09:12:44.000Z",
    "expiresAt": "2026-09-06T09:12:44.000Z",
    "lastUsedAt": "2026-09-05T10:40:11.000Z"
  },
  "valid": true,
  "reason": ""
}

An expired or exhausted key still answers here — that is the point of the endpoint. valid goes false and reason carries the code you would have got from a real call (expired_api_key, quota_exhausted). Only a credential we cannot resolve at all is an error instead.

DELETE/v1/keys/current

Revokes the key on the request, immediately and permanently. Also free. Use it the moment a key ends up somewhere it should not be — in a screenshot, a commit, a support ticket. Revoking does not stop you minting another at the same address.

Revoking costs you nothing and buys you nothing. The allowance belongs to the address for a rolling 24 hours, not to whichever key happens to be alive: a new key is issued for the trial limit minus everything that address has already spent in the window, whether the key that spent it is live, revoked, or expired. So re-minting over a working key and revoking first come out at exactly the same number, and an address that has spent all ten gets 429 allowance_exhausted until the window rolls forward. Mint often enough and you will meet the per-address and per-network mint caps first, which are 429 mint_limit_reached for the rest of the day.

Request body

The three create endpoints share one body: the platform's generation contract, schema version 2. Everything is optional except brief.

Top-level request fields
FieldTypeWhat it does
brief string Required. What you want made, in prose. This is the whole instruction — angle, audience, what to leave out. A vague brief produces vague work; the options tune it, they do not replace it. At most 40,000 characters.
mode See modes Defaults to create. Which modes exist depends on the variantpage takes create and update, blog and deck take create and rewrite. update revises an existing piece against the brief; rewrite starts the text over on the same entity. Both need an entityId. A mode the variant does not accept is a 400 invalid_request.
entityId string · null The thing to act on, required by update and rewrite. 1–180 characters of [A-Za-z0-9_.:-]; anything else is 400 invalid_entity_id. It must be an entity one of your own finished jobs produced, or you get 404 — a blog slug is on the open web and a page id is short, so without that check a guessed id would be a licence to rewrite somebody else's work.
skillIds string[] Extra named behaviours to switch on for this run. Three are always on for every variant and the ceiling is five in total, so at most two of these are yours. Read the ids from GET /v1/skills?variant=….
research.webSearch boolean Let the pipeline search the open web while it works. Off by default. Turning it on makes a run slower and better-sourced.
research.urlCaptures object[] Specific pages to read, at most three. Each entry is { url, includeText, includeScreenshot } — an http(s) URL, text for the words, screenshot when the layout is the point. Both flags default to true.
files object[] Source documents inlined as { name, type, b64 }: a filename, a MIME type, and base64 content. At most ten files and about 20 MB of content, inside a request body capped at 32 MB. This is how you feed it a transcript, a spec, or a spreadsheet it could not otherwise reach.
options object The shaping controls, below. Every one of them is nullable and every one has a sane default.

The options block

Options
OptionValuesApplies to
targetWordsinteger, 250–5000Blog, page. A target, not a contract.
targetSlidesinteger, 3–30Deck.
audiencegeneral · leadership · project-team · externalAll three.
toneinsightful · direct · optimisticAll three.
goalinform · align · persuade · launchAll three.
densityairy · balanced · detailedAll three. How much goes on a page or a slide.
aspectRatio16:9 · 4:3Deck.
speakerNotesbooleanDeck.

Fields you will not need. schemaVersion is fixed at 2 and set for you. variant is implied by the path you post to — send it only if it matches, or not at all (a contradiction is a 400). The visual style is fixed to the Jetski Bay system, so there is no theme to pass.

Two fields are refused outright, not ignored. The shared generation contract has a vaultImageRefs array and an options.addToVault switch. Neither is exposed on the public API, and sending either is a 400 unsupported_field naming the field.

Refusing rather than stripping is the whole point. A silently discarded vaultImageRefs would hand you a finished, billed artifact that you believed contained your images and does not — a failure you would only find by looking at the output closely, long after the request succeeded. An error you cannot miss is the smaller cost.

Where output lands

Two of the three variants finish at a public address. One does not, and it is worth knowing which before you build a UI around it.

Output destinations by variant
VariantDestinationHow you reach it
blog https://blog.jetskibay.com/posts/<slug> Public URL on the finished job.
page https://page.jetskibay.com/p/<id> Public URL on the finished job.
deck GET /v1/decks/{id}/download, with your key.

A deck has no public URL. Presentations live behind a passphrase gate, so there is no link we can hand you that someone else could open. A finished deck job therefore does not carry a liveUrl, and we do not fabricate one — the deck is reachable only as a downloadable artifact through GET /v1/decks/{id}/download, authenticated with the key that made it. If your product needs a shareable link, generate a page, not a deck.

Errors

Every error, from every endpoint, is the same JSON shape: { "error": "…", "code": "…" }. The error string is written to be shown to a person. The code is the stable thing to branch on.

One code carries a third field. invalid_request is what a body the generation contract rejects comes back as, and it lists every problem it found in an issues array — all of them at once, so you are not fixing one field per round trip:

400 · invalid_request
{
  "error": "The request body is not valid: mode rewrite is not valid for page; options.tone must be one of insightful, direct, optimistic.",
  "code": "invalid_request",
  "issues": [
    "mode rewrite is not valid for page",
    "options.tone must be one of insightful, direct, optimistic"
  ]
}
Error codes
StatusCodeMeansDo
400invalid_body The body was not a JSON object at all. Send an object, and a content-type to match.
400invalid_request The body is an object but the generation contract rejects it. Carries issues[], above. Read issues; fix them all before retrying, since the retry costs a request.
409invalid_request An idempotency-key you already used, with a different body. Use a new key, or resend the original body.
400variant_conflict The body set a variant that contradicts the path. Drop the field, or make it match the endpoint.
400unsupported_field vaultImageRefs or options.addToVault — real contract fields, not exposed here. Remove the field. It is not being honoured quietly.
400invalid_entity_id An entityId outside 1–180 characters of [A-Za-z0-9_.:-]. Send the id exactly as a finished job reported it.
400invalid_variant GET /v1/skills with a missing or unknown ?variant=. Pass blog, page or deck.
401missing_api_key No x-api-key and no bearer token on the request. Send the key.
401invalid_api_key The credential is not one of ours. Check for a truncated paste before you re-mint.
401expired_api_key Past its 24 hours. Mint another at the same address.
401revoked_api_key Revoked — by you, or by minting a newer key for the address. Use the newest key you were mailed.
429quota_exhausted The key's requests are used up. Wait for a new key, or ask us about production volume.
429mint_limit_reached Too many key requests from one address or network. Stop asking for keys; use the one you have.
429allowance_exhausted This address has spent its whole trial allowance in the last 24 hours, so there is nothing left to issue a key against. Wait for the window to roll forward, or talk to us about more than a trial.
400invalid_email The address on POST /v1/keys is not deliverable-looking. Fix the address.
502email_failed The key was minted but the mail could not be sent — so the key was revoked again rather than left alive behind an address nobody reads. Retry. It is our failure, not yours, and it cost you no allowance.
404not_found An unknown path under /v1; a job or entity id that does not belong to your address; an entity from a job you never polled to a terminal state. Check the path against GET /v1, and check you polled the job that made it.
405method_not_allowed The path exists but not with that method. POST to create, GET everything else.
400 / 413bad_request A malformed idempotency-key, a body over the 32 MB ceiling, or a skillIds selection that cannot be resolved. Read the message — it names the header, the limit, or the skill.
4xxrejected The generator itself refused the request, and its own status is passed through. Read the message — it comes from the service that said no.
5xxupstream_error A generator failed, timed out, or accepted the work without returning a job. Refunded automatically. Retry. If it persists, check status.
500internal_error We broke in a way we had no name for. Refunded automatically. Retry, and quote the x-request-id if you write to us.
503key_secret_unavailable This deployment has no key-signing secret, so it can neither issue nor verify keys. Nothing you can do. Tell us; every keyed route is down.
503keys_unavailable The key store is not configured on this deployment. Same: ours to fix.
503delivery_unavailable No mail binding, so a minted key could not be delivered — and it is never returned in the response instead. Same: ours to fix.
503api_not_configured Nothing on this deployment can authenticate anybody. Same: ours to fix.
401 Any 401 also carries www-authenticate: Bearer realm="Jetski Bay Content API".

Every response, including every error, carries an x-request-id. It is the one thing worth logging on a failure and the one thing worth quoting to us.

Quota rules

A trial key is 10 requests over 24 hours. The rules for what counts are short, and they are worth reading once rather than discovering.

Need more than a trial — real volume, a longer-lived key, your own vault projects? Say so at hello@jetskibay.com, or go and break the playground first.