Webhooks
A webhook is an address of your app that GPT-Video calls when a job or an export ends. Your program learns that the work is over without reading the job again and again. The events follow the Standard Webhooks specification, so any library made for it can check them.
What an event tells you
An event says that something changed. It carries the ids of what changed, and never a file or a download link. Read the current state with get_job (GET /v1/jobs/{jobId}) or get_export (GET /v1/exports/{exportId}) before you act. For a finished export, get_export gives the download link.
The events are listed below, each with its body and an example. A job event is sent for a job you can read with get_job (GET /v1/jobs/{jobId}). An export event is sent for an export you can read with get_export (GET /v1/exports/{exportId}).
Add a webhook
In the GPT-Video app, open the Developers page, at https://www.gpt-video.com/app/developers, and add the address under Webhooks. The address must be reachable from the internet. Addresses on a private network are refused.
The Developers page shows the secret of the webhook. It starts with whsec_. Keep it on your server, for example in an environment variable, and never in code that runs in a browser.
From the same page you can send a test event, webhook.test. It is sent once, and it does not count when a webhook keeps failing.
Check the signature
Every event comes with three headers.
webhook-idis the id of the event. It is the same on every attempt to send it.webhook-timestampis when this attempt was sent, in seconds since the Unix epoch.webhook-signatureisv1,followed by the base64 HMAC-SHA256 of the id, the timestamp and the raw body, joined by dots.
The key of the HMAC is the part of the secret after whsec_, decoded from base64. Compute the signature on the raw body, before you parse the JSON. A body that was parsed and written again has different bytes, and a real event would then fail the check.
Refuse an event whose timestamp is more than five minutes away from your clock. While a secret is being rotated, webhook-signature carries two signatures separated by a space. Accept the event when either one matches.
Receive webhooks in your app has a receiver in TypeScript and in Python that does all of this.
Delivery
An event can arrive more than once, and events can arrive in a different order from the one in which things happened. A retry carries the same webhook-id and a new webhook-timestamp. Keep the ids you handled and skip an id you have seen.
Answer with a 2xx status as soon as the signature checks out, and do the slow work after the answer. Since events can arrive out of order, decide from the state that get_job (GET /v1/jobs/{jobId}) or get_export (GET /v1/exports/{exportId}) returns.
We keep the records of webhook deliveries for thirty days, as the privacy notice says.
Webhooks or waiting
A webhook needs an address that the internet can reach. A script or an AI agent without one can wait instead. get_job (GET /v1/jobs/{jobId}) and get_export (GET /v1/exports/{exportId}) take ?wait= to hold the request until the work ends. Wait for long jobs explains how. Both return the same state, so you can use either one, or both.
Events
| Event | When it is sent |
|---|---|
job.completed | Sent when a job you can read with get_job (GET /v1/jobs/{jobId}) finishes with status done. Call get_job to read it. |
job.failed | Sent when a job finishes with status failed. A job that is tried again is not failed yet. Call get_job (GET /v1/jobs/{jobId}) to read why. |
job.cancelled | Sent when a job is cancelled before it finishes. |
export.completed | Sent when an export is done. Call get_export (GET /v1/exports/{exportId}) to download its file. |
export.failed | Sent when an export fails, including an export that never started. Call get_export (GET /v1/exports/{exportId}) to read why. |
export.cancelled | Sent when an export is cancelled before it finishes. |
webhook.test | Sent when you send a test event from the Developers page. It is never sent again and does not count when a webhook keeps failing. |
Headers of every event
| Name | Type | Required | Description |
|---|---|---|---|
webhook-id | string | Required | The id of the event. It is the same on every attempt to send the event, so use it to skip an event you already handled. |
webhook-timestamp | string | Required | When this attempt was sent, in seconds since the Unix epoch. A retry carries a new time. It is part of what is signed. |
webhook-signature | string | Required | v1, then a comma, then the base64 HMAC-SHA256 of the webhook-id, the webhook-timestamp and the raw body joined by dots, signed with the secret of the webhook (whsec_...). The key is the part of the secret after whsec_, decoded from base64. While a secret is being rotated it carries two signatures separated by a space, and either one is valid. Any Standard Webhooks library can check it. |
job.completed
Sent when a job you can read with get_job (GET /v1/jobs/{jobId}) finishes with status done. Call get_job to read it.
Body
| Name | Type | Required | Description |
|---|---|---|---|
type | string | Required | The event type. Always job.completed. |
timestamp | string | Required | When the event happened, in UTC. |
data | object | Required | |
data.jobId | string | Required | The job, to read with get_job (GET /v1/jobs/{jobId}). |
data.kind | string | Required | What the job does, as get_job (GET /v1/jobs/{jobId}) says it. One of analysis, build, export, framing, import, story, voices, tool, assistant_turn. |
data.projectId | string or null | Required | The project the job works on. null when the job has none. |
data.compositionId | string or null | Required | The composition the job works on. null when the job has none. |
data.exportId | string or null | Required | The export the job renders. null when the job has none. |
data.assetId | string or null | Required | The video an import brought in. null when the job has none. |
data.toolRunId | string or null | Required | The tool run the job carries out. null when the job has none. |
data.turnId | string or null | Required | The Editor Assistant turn the job carries out. null when the job has none. |
{
"type": "job.completed",
"timestamp": "2026-09-01T09:40:00.000Z",
"data": {
"jobId": "job_6c1e8b4a2f0d4a97e3b5",
"kind": "analysis",
"projectId": "prj_7c2e9a4f1b3d4e8096a5",
"compositionId": null,
"exportId": null,
"assetId": null,
"toolRunId": null,
"turnId": null
}
}job.failed
Sent when a job finishes with status failed. A job that is tried again is not failed yet. Call get_job (GET /v1/jobs/{jobId}) to read why.
Body
| Name | Type | Required | Description |
|---|---|---|---|
type | string | Required | The event type. Always job.failed. |
timestamp | string | Required | When the event happened, in UTC. |
data | object | Required | |
data.jobId | string | Required | The job, to read with get_job (GET /v1/jobs/{jobId}). |
data.kind | string | Required | What the job does, as get_job (GET /v1/jobs/{jobId}) says it. One of analysis, build, export, framing, import, story, voices, tool, assistant_turn. |
data.projectId | string or null | Required | The project the job works on. null when the job has none. |
data.compositionId | string or null | Required | The composition the job works on. null when the job has none. |
data.exportId | string or null | Required | The export the job renders. null when the job has none. |
data.assetId | string or null | Required | The video an import brought in. null when the job has none. |
data.toolRunId | string or null | Required | The tool run the job carries out. null when the job has none. |
data.turnId | string or null | Required | The Editor Assistant turn the job carries out. null when the job has none. |
{
"type": "job.failed",
"timestamp": "2026-09-01T09:40:00.000Z",
"data": {
"jobId": "job_6c1e8b4a2f0d4a97e3b5",
"kind": "analysis",
"projectId": "prj_7c2e9a4f1b3d4e8096a5",
"compositionId": null,
"exportId": null,
"assetId": null,
"toolRunId": null,
"turnId": null
}
}job.cancelled
Sent when a job is cancelled before it finishes.
Body
| Name | Type | Required | Description |
|---|---|---|---|
type | string | Required | The event type. Always job.cancelled. |
timestamp | string | Required | When the event happened, in UTC. |
data | object | Required | |
data.jobId | string | Required | The job, to read with get_job (GET /v1/jobs/{jobId}). |
data.kind | string | Required | What the job does, as get_job (GET /v1/jobs/{jobId}) says it. One of analysis, build, export, framing, import, story, voices, tool, assistant_turn. |
data.projectId | string or null | Required | The project the job works on. null when the job has none. |
data.compositionId | string or null | Required | The composition the job works on. null when the job has none. |
data.exportId | string or null | Required | The export the job renders. null when the job has none. |
data.assetId | string or null | Required | The video an import brought in. null when the job has none. |
data.toolRunId | string or null | Required | The tool run the job carries out. null when the job has none. |
data.turnId | string or null | Required | The Editor Assistant turn the job carries out. null when the job has none. |
{
"type": "job.cancelled",
"timestamp": "2026-09-01T09:40:00.000Z",
"data": {
"jobId": "job_6c1e8b4a2f0d4a97e3b5",
"kind": "analysis",
"projectId": "prj_7c2e9a4f1b3d4e8096a5",
"compositionId": null,
"exportId": null,
"assetId": null,
"toolRunId": null,
"turnId": null
}
}export.completed
Sent when an export is done. Call get_export (GET /v1/exports/{exportId}) to download its file.
Body
| Name | Type | Required | Description |
|---|---|---|---|
type | string | Required | The event type. Always export.completed. |
timestamp | string | Required | When the event happened, in UTC. |
data | object | Required | |
data.exportId | string | Required | The export, to read with get_export (GET /v1/exports/{exportId}). |
data.compositionId | string | Required | The composition the export renders. |
data.projectId | string | Required | The project of the composition. |
data.jobId | string or null | Required | The job that renders the export. null for an export that never started. |
{
"type": "export.completed",
"timestamp": "2026-09-01T10:15:00.000Z",
"data": {
"exportId": "rnd_0f6b3e9d2c7a4158b4a3",
"compositionId": "cmp_4e8c2a6f09d14b73a1e5",
"projectId": "prj_7c2e9a4f1b3d4e8096a5",
"jobId": "job_a3e9d05c7b1f4e2896d4"
}
}export.failed
Sent when an export fails, including an export that never started. Call get_export (GET /v1/exports/{exportId}) to read why.
Body
| Name | Type | Required | Description |
|---|---|---|---|
type | string | Required | The event type. Always export.failed. |
timestamp | string | Required | When the event happened, in UTC. |
data | object | Required | |
data.exportId | string | Required | The export, to read with get_export (GET /v1/exports/{exportId}). |
data.compositionId | string | Required | The composition the export renders. |
data.projectId | string | Required | The project of the composition. |
data.jobId | string or null | Required | The job that renders the export. null for an export that never started. |
{
"type": "export.failed",
"timestamp": "2026-09-01T10:15:00.000Z",
"data": {
"exportId": "rnd_0f6b3e9d2c7a4158b4a3",
"compositionId": "cmp_4e8c2a6f09d14b73a1e5",
"projectId": "prj_7c2e9a4f1b3d4e8096a5",
"jobId": "job_a3e9d05c7b1f4e2896d4"
}
}export.cancelled
Sent when an export is cancelled before it finishes.
Body
| Name | Type | Required | Description |
|---|---|---|---|
type | string | Required | The event type. Always export.cancelled. |
timestamp | string | Required | When the event happened, in UTC. |
data | object | Required | |
data.exportId | string | Required | The export, to read with get_export (GET /v1/exports/{exportId}). |
data.compositionId | string | Required | The composition the export renders. |
data.projectId | string | Required | The project of the composition. |
data.jobId | string or null | Required | The job that renders the export. null for an export that never started. |
{
"type": "export.cancelled",
"timestamp": "2026-09-01T10:15:00.000Z",
"data": {
"exportId": "rnd_0f6b3e9d2c7a4158b4a3",
"compositionId": "cmp_4e8c2a6f09d14b73a1e5",
"projectId": "prj_7c2e9a4f1b3d4e8096a5",
"jobId": "job_a3e9d05c7b1f4e2896d4"
}
}webhook.test
Sent when you send a test event from the Developers page. It is never sent again and does not count when a webhook keeps failing.
Body
| Name | Type | Required | Description |
|---|---|---|---|
type | string | Required | The event type. Always webhook.test. |
timestamp | string | Required | When the event happened, in UTC. |
data | object | Required | |
data.endpointId | string | Required | The webhook the test was sent to. |
{
"type": "webhook.test",
"timestamp": "2026-10-03T12:00:00.000Z",
"data": {
"endpointId": "whk_ea7c528e7e454010d9fc"
}
}