Skip to main content
Add webhook and webhook_secret to the JSON body when you submit an asynchronous task. When the task reaches a final state, FreeModel sends one signed POST to your URL, so you don’t have to keep polling. Webhooks work on POST /v1/videos and on the GPT Image models (GPT Image 2 and GPT Image 2.5 Ext) on POST /v1/images/generations.
GET /v1/tasks/{id} stays the source of truth. The webhook body is an event: the task sits under data.task, uses the same fields as Get task status, and reports gateway statuses such as succeeded. Video POST /v1/videos still returns object: "video" at submit time.

Quick start

Generate the secret once, for example with openssl rand -hex 32, and keep it on your server.
string
The public https URL that receives the event. Up to 2,048 characters, with no user:password@ credentials and no #fragment.
string
Required with webhook. 16 to 256 characters, with no control characters. FreeModel signs every delivery with it, stores it encrypted, and never returns it. Each task keeps the secret it was submitted with.
Send both fields in a JSON request body. If one is missing or breaks these rules, the submission fails with HTTP 400 and error code invalid_task_command, and nothing is charged.

Events

Each task sends one event, when it reaches a final state. There are no progress events. type is task. followed by the final status: task.succeeded, task.failed, task.expired, or task.reconciliation_required.
  • task.succeeded: save the output from data.task.result.
  • Any other type: data.task.error has a code and a message. For task.reconciliation_required, the final outcome isn’t confirmed yet: check GET /v1/tasks/{id} later, and don’t resubmit the job to another route.

Payload

Most tasks list their files in result.urls. Some video models return result.artifacts without a direct URL, so for videos, download through GET /v1/videos/{id}/content, which works for every video model.

Headers

Verify the signature

The signature is an HMAC-SHA256 of <timestamp>.<raw body>, keyed with your webhook_secret and hex-encoded, where <timestamp> is the X-Gateway-Webhook-Timestamp value. Compute it over the raw body bytes before you parse the JSON, compare in constant time, and reject timestamps more than five minutes old. Every retry is signed again with a new timestamp.

Retries and deduplication

  • Return any 2xx within 10 seconds. Acknowledge first, then process asynchronously.
  • Timeouts, connection errors, 408, 409, 425, 429, and 5xx are retried, up to 12 attempts in total. The first retry follows within a few seconds, and the wait doubles after each failure, up to one hour, with random jitter. The last attempt comes roughly one to two and a half hours after the first.
  • A Retry-After header on your response, in seconds or as an HTTP date, sets the next wait, up to one hour.
  • Any other 4xx, any 3xx (redirects are not followed), and TLS certificate errors stop delivery at once.
  • The same delivery can arrive more than once, for example when your 2xx is lost on the way back. Deduplicate on the body id.
  • Webhooks are an optimization, not the source of truth. If delivery stops or every attempt fails, GET /v1/tasks/{id} still has the result, so keep a reconciliation poll for tasks you haven’t heard back about.

Webhook URL requirements

Developing locally? Private addresses can’t receive webhooks. Expose your handler with a tunnel, for example cloudflared tunnel or ngrok http 3000, and use the tunnel’s https URL, or poll GET /v1/tasks/{id} during development.

Minimal receiver

Both receivers use the verifyWebhook / verify_webhook function from Verify the signature. A 401 for a bad signature stops retries for that delivery, which is what you want for forged requests.

FAQ

Check, in order: (1) has the task finished? Fetch GET /v1/tasks/{id}; webhooks fire only on final states. (2) Is the URL a public https address with a valid certificate? (3) Does your endpoint return 2xx within 10 seconds without redirecting? A 3xx, or a 4xx other than 408, 409, 425, and 429, stops delivery.
The webhook fields didn’t pass validation. Send both webhook and webhook_secret, use a public https URL, and make the secret 16 to 256 characters long.
A task can produce more than one file, for example an image request with n greater than 1. Handle it as an array even when it holds one URL.
Polling stays fully supported. Webhooks are an optimization: if a delivery is missed, GET /v1/tasks/{id} always has the latest state. For anything critical, keep a reconciliation poll as the fallback.
No. Private addresses are rejected. Use a tunnel such as cloudflared or ngrok during development, or poll instead.