Webhooks
Pass a webhook_url when you submit a job and we'll POST to it when the
job finishes, instead of you having to poll. The webhook is configured per request: each job
carries its own destination, so you can route clips to different endpoints for different purposes. A
job submitted without a webhook_url sends no webhook.
When it fires
Once per job outcome, whichever way it goes:
job.completed— the clip rendered and is ready.job.failed— the job will never produce a clip (a rejecteddemo_url, an unplayable demo, a render failure, or a download that we retried until we gave up).error_codetells you which.
Nothing fires while a job is still on its way. A transient failure we intend to retry — a slow or
dropped demo download — is not an outcome, so it stays silent; you'll get job.completed if a later
attempt works, or job.failed once we stop trying. Either way you hear from us exactly once per
attempt at the job.
There is no "submitted" event. The synchronous { "job_id": "..." } response from
POST /api/clips is your confirmation that the job was accepted.
If you re-run a job — either with force: true or by
resubmitting it — it gets a fresh webhook for its new outcome,
and we cancel the previous outcome's webhook if it hasn't been delivered yet.
Request
POSTto thewebhook_urlsupplied on the job.Content-Type: application/json.- 10 second timeout per attempt.
- Optional
Authorizationheader. Passwebhook_auth_headeralongsidewebhook_urlwhen you submit the job to have that value sent as theAuthorizationheader on the webhook request. It's forwarded verbatim — set it to whatever your endpoint expects (e.g.Bearer <token>orBasic <credentials>) and use it to authenticate the request on your end. Omit it and noAuthorizationheader is sent. Sendingwebhook_auth_headerwithout awebhook_urlreturns a400. - Not signed. There is no header proving a request originated from dinkem.gg. If you need to
verify authenticity, use
webhook_auth_headerabove, or cross-checkjob_id/clip_idagainst values you already hold from your ownPOST /api/clipscalls.
Headers
| Header | Description |
|---|---|
X-Dinkem-Delivery-Id | Unique ID for this webhook. Stable across every retry of the same webhook — this is what you deduplicate on. |
X-Dinkem-Attempt | Which attempt this is, starting at 1. |
Retries
Respond 2xx to accept a webhook. Anything else — any non-2xx status, a timeout, a connection error — is treated as a failed delivery and retried.
- 10 attempts total: the first immediately, then 9 more 15 minutes apart. After that we stop
and the webhook is never delivered — though you can trigger a fresh delivery on demand with
POST /api/jobs/:id/resend-webhook. - Every non-2xx is retried, including 4xx. We don't try to distinguish "your endpoint is
misconfigured" from "your endpoint is briefly down mid-deploy" — a
404during a rollout gets the same treatment as a503.
Respond quickly and do your real work asynchronously. If you need longer than 10 seconds to process a clip, return 2xx first and queue it on your side; a slow endpoint reads as a failure and gets retried, which usually means processing the same clip twice.
Delivery is at-least-once
You may receive the same webhook more than once, so make your handler idempotent and dedupe on
X-Dinkem-Delivery-Id. This isn't rare or theoretical: if your 2xx is lost in transit, or arrives
after our 10 second timeout, we can't tell that apart from a failure and will retry a webhook you
already processed successfully.
Delivery can be out of order
Webhooks are not ordered, and a retry schedule spanning two hours makes that visible. If you redo
a job while an earlier outcome is still being retried, that older webhook can still land after the
newer one — for example a job.failed arriving after the job.completed that superseded it. We
cancel undelivered webhooks when you redo a job, but one already in flight will still arrive.
Treat the job's current state as the truth, not the last webhook you received. If ordering matters to you, fetch the job (or the clip) and confirm before acting on a failure.
Payload
Both events share one shape, so you can parse a single struct and switch on event. The fields that
don't apply to an event are null rather than absent.
| Field | Type | Description |
|---|---|---|
event | string | "job.completed" or "job.failed". |
job_id | string | The job ID returned by POST /api/clips. |
clip_id | string, nullable | ID of the rendered clip. Null on job.failed. |
clip_url | string, nullable | Public URL to an embeddable player page for the rendered clip — drop it straight into an <iframe>. This is an HTML page, not a media file. Null on job.failed. |
video_url | string, nullable | Public URL to the rendered mp4 itself. Use this wherever a media URL is expected — a <video> tag, a download, or an og:video tag (see Embedding Clips). Null on job.failed. |
thumbnail_url | string, nullable | Public URL to a poster JPG. Null on job.failed. |
clip_duration | number, nullable | Length of the rendered clip in seconds, as a float. Null on job.failed. 0 in the rare case the clip rendered but we couldn't read its duration — the clip itself is still fine, so don't treat 0 as a failure. |
status | string | "processed" on job.completed, "error" on job.failed. |
error_code | string, nullable | Why the job failed — see Error codes. Null on job.completed. |
extras | any JSON value, nullable | Echoed back exactly as sent in the request's extras field (see Submitting Render Jobs), or null if you didn't send one. Round-tripped unmodified — object/array/number/string/boolean values keep their original JSON type, nothing is stringified. |
Example: job.completed
{
"event": "job.completed",
"job_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"clip_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"clip_url": "https://dinkem.gg/clips/3fa85f64-5717-4562-b3fc-2c963f66afa6/embed",
"video_url": "https://hel1.your-objectstorage.com/dnkm/3fa85f64-5717-4562-b3fc-2c963f66afa6.mp4",
"thumbnail_url": "https://hel1.your-objectstorage.com/dnkm/3fa85f64-5717-4562-b3fc-2c963f66afa6.jpg",
"clip_duration": 24.5,
"status": "processed",
"error_code": null,
"extras": {
"gameId": "0408314d-53fa-4fa6-8770-c890171319b8",
"steam64Id": "76561198012345678",
"roundNumber": 14
}
}
Example: job.failed
{
"event": "job.failed",
"job_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"clip_id": null,
"clip_url": null,
"video_url": null,
"thumbnail_url": null,
"clip_duration": null,
"status": "error",
"error_code": "demo_incompatible",
"extras": {
"gameId": "0408314d-53fa-4fa6-8770-c890171319b8",
"steam64Id": "76561198012345678",
"roundNumber": 14
}
}
Error codes
error_code accompanies every job.failed and names what went wrong.
| Code | Meaning |
|---|---|
demo_url_rejected | The demo_url isn't on a domain we accept. Nothing to retry — fix the URL. |
demo_404 | The demo URL returned 404. Nothing to retry — the file is missing or expired. |
demo_incompatible | We couldn't play the demo with the current CS2 build. |
player_not_in_demo | The steam64_id you submitted isn't one of the players in this demo. Nothing to retry — check the id is right for this demo, or that you sent the right demo. |
no_kills_found | The player has no kills in the round(s) you requested, so there's nothing to render. Nothing to retry — check the round number, or that the player got a kill in it. |
workshop_not_supported | The demo was recorded on a workshop map that requires additional Steam Workshop content we don't currently support. Nothing to retry — the same demo will fail again. |
demo_failed_parse | We couldn't parse the demo file — it looks incomplete or corrupted (the recording appears cut off). Nothing to retry — the same file will fail again. |
decompress_failed | We couldn't decompress the demo archive — the download looks truncated or corrupted (a checksum mismatch). Nothing to retry — the same file will fail again. |
download_timeout | We couldn't download the demo fast enough, across every retry. Usually the host serving it. |
download_error | The demo download failed at the network level, across every retry. |
cs2_unavailable | A render machine crashed or stalled mid-render. The demo is fine — we retry it automatically on another machine, so this only reaches you if every retry hit the same trouble. |
render_failed | The render itself failed. |
upload_error | The clip rendered, but uploading it to our storage failed. |
finalize_failed | The clip rendered, but we couldn't finish processing it. |
This list is open-ended. New codes get added as we learn to name failures more precisely, and
that is not a breaking change — so don't exhaustively switch on it. Treat any code you don't
recognize as a generic failure.
download_timeout, download_error, and cs2_unavailable reaching you at all means we already
retried the job repeatedly and exhausted it; there's no partial state to reconcile.
Status values
status is a coarser signal than error_code, for integrations that only need "did it work":
processed— always paired withjob.completed.error— always paired withjob.failed.submitted— represented by the synchronousPOST /api/clipsresponse, not a webhook.ondemand— not a concept in dinkem.gg; this value never applies here.