Behaviour
Webhooks
Register an https URL and Kinoseed POSTs a signed event to it the moment a job completes or fails, so a script or an agent does not have to hold a poll open.
Events
| Type | When |
|---|---|
| job.completed | A job finished. data.output holds its URLs, already copied to our storage, so they do not expire with the provider's. |
| job.failed | A job failed, at submission or later. data.error says why and data.credits.refunded shows the refund, which has already happened. |
| ping | Sent only by POST /webhooks/{id}/test. data has the endpointId. |
Events go to every active endpoint of the account that subscribes to them, for runs started through the API, the MCP server or the web app alike. An endpoint subscribes to both job events unless events says otherwise.
Payload
The body is JSON: an id (evt_..., also in the Kinoseed-Event-Id header), the type, createdAt, and data, which is the job exactly as GET /jobs/{id} returns it (Job). Every retry of an event sends the same bytes with the same id, so use the id to drop duplicates.
POST /hooks/kinoseed HTTP/1.1
Content-Type: application/json
Kinoseed-Signature: t=1793000000,v1=5f2b...e41a
Kinoseed-Event-Id: evt_8c1d0b6e4f2a4e9b9a3c7d5e1f0a2b3c
Kinoseed-Event-Type: job.completed
{
"id": "evt_8c1d0b6e4f2a4e9b9a3c7d5e1f0a2b3c",
"type": "job.completed",
"createdAt": "2026-10-25T18:02:11.000Z",
"data": {
"id": "JOB_ID",
"kind": "image",
"status": "completed",
"progress": 100,
"model": "nano-banana-pro",
"prompt": "a ceramic mug on a sunlit desk, product photo",
"credits": { "charged": 4, "refunded": 0 },
"output": { "videoUrl": null, "imageUrls": ["https://..."], "thumbUrl": "https://..." },
"error": null,
"metadata": { "order": "1042" },
"createdAt": "2026-10-25T18:01:40.000Z",
"completedAt": "2026-10-25T18:02:09.000Z",
"links": { "self": "https://kinoseed.app/api/v1/jobs/JOB_ID", "web": "https://kinoseed.app/library" }
}
}Verifying the signature
Every request carries Kinoseed-Signature: t=<unix seconds>,v1=<hex>, where the hex is the HMAC-SHA256 of t + "." + raw body, keyed with the whole secret string, whsec_ included. To accept an event:
- Read the raw body before parsing it; re-serialized JSON will not match.
- Compute the HMAC and compare it with
v1in constant time. - Refuse a
tmore than 5 minutes from your clock, so a captured request cannot be replayed later.
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.KINOSEED_WEBHOOK_SECRET!; // whsec_..., from POST /webhooks
const TOLERANCE_SECONDS = 5 * 60;
export function verifyKinoseedSignature(rawBody: string, header: string | null): boolean {
if (!header) return false;
const parts = new Map(
header.split(",").map((p): [string, string] => {
const i = p.indexOf("=");
return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
}),
);
const t = Number(parts.get("t"));
const v1 = parts.get("v1");
if (!Number.isInteger(t) || !v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", SECRET).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(v1, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}
// A Next.js route handler. Read the raw text first: the signature covers the
// exact bytes, so parse JSON only after it checks out.
export async function POST(req: Request) {
const raw = await req.text();
if (!verifyKinoseedSignature(raw, req.headers.get("kinoseed-signature"))) {
return new Response("bad signature", { status: 400 });
}
const event = JSON.parse(raw); // { id, type, createdAt, data: Job }
// Deliveries can repeat: skip an event.id you have already handled.
if (event.type === "job.completed") console.log(event.data.id, event.data.output);
if (event.type === "job.failed") console.log(event.data.id, event.data.error);
return new Response(null, { status: 204 });
}
Delivery and retries
The first attempt is made as soon as the job is final. Answer with any 2xx status within 5 seconds; do slow work after answering. Anything else (another status, a redirect, a timeout, a refused connection) is a failed attempt, retried after 1 min, 5 min, 30 min, 2 h: 5 attempts in all, after which the delivery is dropped. Redirects are not followed, and the URL must resolve to a public address every time.
After 20 failed attempts in a row, across all events, the endpoint is switched off: active turns false and disabledAt is set. One successful delivery resets the count. Once your server is fixed, send a test event: a 2xx answer switches the endpoint back on. Events are not stored for replay, so GET /jobs (newest first) is how to catch up on what finished while it was off.
Limits
- 5 endpoints per account, active or not; a sixth is
limit_reached(409). - Public https URLs only: private addresses, localhost, IP literals and plain http are
unsafe_url. - Test events: 10 an hour per endpoint, 30 an hour per account.
- Managing endpoints needs a key with the
webhooks:managescope, which new keys do not get unless it is chosen when the key is created (see Authentication).
Create a webhook endpoint
post/api/v1/webhooks
Register a public https URL to receive job.completed and job.failed events, signed with the returned secret. The secret is in this response only. Up to 5 endpoints per account. Needs webhooks:manage.
curl -X POST https://kinoseed.app/api/v1/webhooks \
-H "Authorization: Bearer ks_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/hooks/kinoseed", "events": ["job.completed", "job.failed"] }'
# The response holds "secret": "whsec_..." once. Store it now.
Request body
application/json
| Field | Type | Required | Description |
|---|---|---|---|
| url | string (uri) | yes | A public https URL that accepts POST. Private addresses, IP literals and plain http are refused.up to 2048 characters · e.g. "https://example.com/hooks/kinoseed" |
| events | WebhookEventType[] | no | The events to send. Defaults to both. |
Responses
| Status | Body | Meaning |
|---|---|---|
| 201 | WebhookCreatedResponse | Created. Store secret now; it is never shown again. |
| 400 | ErrorEnvelope | Body failed validation (invalid_request), or the URL is not public https (unsafe_url). |
| 401 | ErrorEnvelope | Missing, invalid or revoked key. |
| 403 | ErrorEnvelope | The key lacks webhooks:manage (keys do not get it unless it is ticked at creation). |
| 409 | ErrorEnvelope | The account already has 5 endpoints. |
| 429 | ErrorEnvelope | Over a rate budget; see Retry-After. Also too_many_running when the account already has 5 runs in progress. |
Response fields
The 201 body.
No fields.
List webhook endpoints
get/api/v1/webhooks
The account's webhook endpoints, oldest first. Secrets are never returned here. Needs webhooks:manage.
curl https://kinoseed.app/api/v1/webhooks \
-H "Authorization: Bearer ks_live_YOUR_KEY"
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | WebhooksListResponse | The endpoints. |
| 401 | ErrorEnvelope | Missing, invalid or revoked key. |
| 403 | ErrorEnvelope | The key lacks webhooks:manage (keys do not get it unless it is ticked at creation). |
| 429 | ErrorEnvelope | Over a rate budget; see Retry-After. Also too_many_running when the account already has 5 runs in progress. |
Send a test event
post/api/v1/webhooks/{id}/test
Send a signed ping event to the endpoint now and answer with its HTTP status and latency. A 2xx answer switches a disabled endpoint back on and clears its failure count. Limited to 10 tests an hour per endpoint and 30 per account. error is one of: connection refused, timed out, DNS lookup failed, TLS error, blocked address, network error, or HTTP <status>. Needs webhooks:manage.
curl -X POST https://kinoseed.app/api/v1/webhooks/WEBHOOK_ID/test \
-H "Authorization: Bearer ks_live_YOUR_KEY"
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | The webhook endpoint id. |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | WebhookTestResponse | What the endpoint answered. delivered is false for a non-2xx status, a timeout or an unreachable host. |
| 401 | ErrorEnvelope | Missing, invalid or revoked key. |
| 403 | ErrorEnvelope | The key lacks webhooks:manage (keys do not get it unless it is ticked at creation). |
| 404 | ErrorEnvelope | No webhook endpoint with this id belongs to this account. |
| 429 | ErrorEnvelope | Over a rate budget; see Retry-After. Also too_many_running when the account already has 5 runs in progress. |
Response fields
The 200 body.
| Field | Type | Description |
|---|---|---|
| delivered | boolean | Whether the endpoint answered with a 2xx status. |
| status | integer | null | The endpoint's HTTP status; null when it could not be reached. |
| latencyMs | integer | |
| error | string | null | |
| eventId | string | The evt_ id of the ping that was sent. |
Delete a webhook endpoint
delete/api/v1/webhooks/{id}
Remove an endpoint. Deliveries still waiting for a retry are dropped with it. Needs webhooks:manage.
curl -X DELETE https://kinoseed.app/api/v1/webhooks/WEBHOOK_ID \
-H "Authorization: Bearer ks_live_YOUR_KEY"
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | The webhook endpoint id. |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | WebhookDeleteResponse | Deleted. |
| 401 | ErrorEnvelope | Missing, invalid or revoked key. |
| 403 | ErrorEnvelope | The key lacks webhooks:manage (keys do not get it unless it is ticked at creation). |
| 404 | ErrorEnvelope | No webhook endpoint with this id belongs to this account. |
| 429 | ErrorEnvelope | Over a rate budget; see Retry-After. Also too_many_running when the account already has 5 runs in progress. |
Response fields
The 200 body.
| Field | Type | Description |
|---|---|---|
| id | string | |
| deleted | true |