Aller au contenu

Async jobs & webhooks

Ce contenu n’est pas encore disponible dans votre langue.

Every inference route has a synchronous form: you POST, you hold the connection, you get the result. That breaks down when the work takes longer than a client is willing to wait — image generation, long transcriptions, batch embeddings.

The async jobs endpoint is the same inference surface, decoupled. You POST a job, get a job_id back in milliseconds, and we deliver the result to an HTTPS endpoint you own.

One endpoint, every modality

A single POST …/jobs takes a kind field: chat, embeddings, rerank, audio, images, forecast. No per-modality async route to learn.

Your context, returned verbatim

Attach any JSON context (an order id, a user id). It comes back untouched in the callback, so you don’t need a lookup table.

Signed callbacks

Each delivery carries an HMAC-SHA256 signature over the timestamp and body, so you can prove it came from us.

Retries with backoff

A callback that fails is retried five times over roughly six hours. A brief outage on your side doesn’t lose the result.

Before you start

Async delivery needs two things configured on the workload, both in the dashboard under Workloads → your workload → Endpoints:

  1. Enable the jobs toggle. It gates the async surface as a whole.

  2. Enable the toggle for the modality you’ll submit. A kind: "chat" async job needs both jobs and chat enabled. This is deliberate: turning off the sync chat route shouldn’t leave chat reachable through the async one.

  3. Add a webhook destination. The URL must be https:// and publicly resolvable — see Webhook URL requirements. Its signing secret is generated at the same time and shown once; copy it before leaving the panel. Then press Test on the saved row to send a signed ping.

Delivering to more than one place

A workload can carry up to 10 destinations, and every job result is delivered to all of them. That’s how you feed two consumer projects — say a billing service and an analytics pipeline — from a single workload without either one relaying to the other.

Each destination is independent in all three senses that matter:

  • Its own signing secret. Rotating one consumer’s secret never invalidates another’s, so a compromised integration is contained.
  • Its own retry schedule. A consumer that is down retries (and eventually goes terminal) on its own; the healthy destinations are unaffected and never see a duplicate because of it.
  • Its own on/off switch. Muting a destination keeps its secret, so you can pause an integration without re-onboarding it later.

The jobs toggle remains the master switch: turn it off and nothing is delivered anywhere, whatever the individual destinations say.

1. Submit a job

POST /endpoint/{project}/{workload}/jobs
Authorization: Bearer ik_live_…
Content-Type: application/json

The envelope has three fields: the kind that picks the modality, the body that is the ordinary OpenAI-shaped request for that modality, and your optional context.

Request
{
"kind": "embeddings",
"body": {
"model": "bge-m3",
"input": "the quick brown fox"
},
"context": { "orderId": "ord_8821", "userId": "u_42" }
}

You get 202 Accepted immediately:

Response
{ "job_id": "9f1c3a7e-5d02-4a11-b7c8-2e6481c1cf29" }

That job_id appears in the callback, so you can correlate. The response comes back as soon as the job is queued — it says nothing about whether inference succeeded.

import os, requests
BASE = "https://cloud.inferencekey.com"
resp = requests.post(
f"{BASE}/endpoint/gpu-workloads/my-workload/jobs",
headers={"Authorization": f"Bearer {os.environ['INFERENCEKEY_API_KEY']}"},
json={
"kind": "embeddings",
"body": {"model": "bge-m3", "input": "the quick brown fox"},
"context": {"orderId": "ord_8821"},
},
timeout=30,
)
resp.raise_for_status()
job_id = resp.json()["job_id"]

2. The kind field, per modality

body is exactly the body you would have sent to the synchronous route — same shape, same fields. Only the wrapper changes.

kindSync equivalentNotes
chat (or chat_completions)POST /v1/chat/completionsValidated against the chat schema at submit time. stream is ignored — async never streams.
embeddingsPOST /v1/embeddings
rerank (or reranking)POST /v1/rerank
audio_speechPOST /v1/audio/speechText → audio (TTS).
audio_generatePOST /v1/audio/generateGenerative audio (diffusion).
audio_transcriptionsPOST /v1/audio/transcriptionsAudio → text. Takes a file — see file uploads.
images (or images_generations)POST /v1/images/generationsText → image.
images_editsPOST /v1/images/editsTakes a file.
images_variationsPOST /v1/images/variationsTakes a file.
forecastPOST …/forecastTime-series forecasting on a custom backend.

An unrecognized kind returns 400 listing the accepted values.

3. File uploads (audio and images)

Transcriptions, image edits and image variations are file uploads: synchronously you send them as multipart/form-data. The async envelope is JSON, so a multipart body can’t be nested inside it directly.

Instead, send the exact bytes you would have POSTed as base64 in body_b64, and declare the matching content_type:

Async transcription
{
"kind": "audio_transcriptions",
"body_b64": "LS0tLS0tV2ViS2l0Rm9ybUJvdW5kYXJ5…",
"content_type": "multipart/form-data; boundary=----WebKitFormBoundaryAbC123",
"context": { "recordingId": "rec_5" }
}

We forward those bytes to the model runtime untouched, so the multipart boundary survives intact.

Rules for the two forms:

  • Send either body (JSON) or body_b64 + content_type (raw bytes) — never both, and never neither. Both cases return 400.
  • body_b64 is not accepted for kind: "chat"; chat is always JSON.
  • Malformed base64 is rejected at submit time, so you find out immediately rather than through a failed callback.
import base64, os, requests
from requests_toolbelt.multipart.encoder import MultipartEncoder
# Build the same multipart body the sync route would receive.
enc = MultipartEncoder(fields={
"model": "whisper-large-v3",
"file": ("audio.mp3", open("audio.mp3", "rb"), "audio/mpeg"),
})
raw = enc.to_string() # bytes, with the boundary baked in
requests.post(
"https://cloud.inferencekey.com/endpoint/gpu-workloads/my-stt/jobs",
headers={"Authorization": f"Bearer {os.environ['INFERENCEKEY_API_KEY']}"},
json={
"kind": "audio_transcriptions",
"body_b64": base64.b64encode(raw).decode(),
# enc.content_type carries the exact boundary used above.
"content_type": enc.content_type,
"context": {"recordingId": "rec_5"},
},
timeout=30,
).raise_for_status()

4. The callback you receive

When the job finishes we POST to your webhook URL:

POST https://your-app.example.com/hooks/inferencekey
Content-Type: application/json
X-InferenceKey-Event: job
X-InferenceKey-Job-Id: 9f1c3a7e-5d02-4a11-b7c8-2e6481c1cf29
X-InferenceKey-Signature: t=1769385600,v1=5257a869e7ecebe7…
Callback body
{
"event": "job.completed",
"ts": "2026-07-25T18:40:00Z",
"job_id": "9f1c3a7e-5d02-4a11-b7c8-2e6481c1cf29",
"context": { "orderId": "ord_8821", "userId": "u_42" },
"data": {
"job_id": "9f1c3a7e-5d02-4a11-b7c8-2e6481c1cf29",
"status": "ok",
"payload": { "data": [{ "embedding": [0.013, -0.271, ""] }] },
"metrics": { "…": "" },
"usage": { "input_tokens": 7, "output_tokens": 0, "total_tokens": 7 }
}
}

Top-level fields:

FieldMeaning
eventjob.completed on success, job.failed on error.
tsRFC 3339 timestamp of the delivery attempt.
job_idMatches the job_id from your 202 response.
contextYour context, verbatim. Absent if you didn’t send one.
dataThe job result (see below).

Inside data:

FieldMeaning
statusok or an error status.
payloadThe JSON result, for JSON-returning modalities (embeddings, rerank, chat, forecast).
outputPlain-text output, when the modality produces text.
binary_b64Base64 result bytes for binary modalities (audio, images).
content_typeMIME type that pairs with binary_b64, e.g. image/png.
usageToken counts, when the runtime reports them.
unitsPhysical units — audio seconds, images generated.
errorPresent when event is job.failed; carries the failure reason.

Always branch on event (or data.status) before reading the result: a job.failed callback is a normal delivery, not a transport error.

5. Verify the signature

If you generated a signing secret, every delivery carries X-InferenceKey-Signature:

t=1769385600,v1=<hex hmac-sha256>

The signed value is the timestamp, a literal dot, then the raw request body: "{t}.{body}". Compute HMAC-SHA256 with your secret and compare against v1 in constant time.

import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
ts, sig = parts.get("t"), parts.get("v1")
if not ts or not sig:
return False
# Reject stale timestamps so a captured callback can't be replayed.
if abs(time.time() - int(ts)) > tolerance:
return False
expected = hmac.new(
secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, sig)
Flask handler
@app.post("/hooks/inferencekey")
def hook():
if not verify(request.get_data(), request.headers.get("X-InferenceKey-Signature", ""), SECRET):
return "", 401
event = request.get_json()
# Ack fast, then do the real work out of band.
enqueue_processing(event)
return "", 200

If you haven’t generated a secret, the header is omitted entirely — deliveries still arrive, unsigned. Generate one before going to production.

The test ping is signed too

The dashboard’s Test endpoint button sends a ping to your URL. Once the destination has a signing secret, that ping is signed with the same scheme as a real callback — same X-InferenceKey-Signature header, same <timestamp>.<body> value — so one verification routine covers both. Tell them apart by the event header, not by whether a signature is present:

X-InferenceKey-EventSigned?
Test pingpingYes, once a secret exists
Job resultjobYes, whenever a secret exists

Adding a destination mints its signing secret in the same step, so every ping you can send from the dashboard is signed. The secret’s plaintext is shown once, right after you press Add — copy it then; afterwards only its prefix is visible.

That’s why the order is add-then-test, not test-then-add: a ping can only be signed once the destination exists.

6. Retries and delivery guarantees

Respond 2xx to accept a delivery. Anything else — a 4xx, a 5xx, a timeout, a connection failure — is treated as a failure and retried:

AttemptDelay after previous
1 → 21 minute
2 → 35 minutes
3 → 415 minutes
4 → 51 hour
5 → 66 hours

After the sixth failure the delivery is marked terminal and no longer retried. You can see terminal deliveries in the dashboard. This schedule is per destination: when a workload has several, each one retries on its own and one going terminal doesn’t affect the rest.

Two constraints worth designing around:

  • We wait 3 seconds. A response slower than that counts as a timeout, even if your handler eventually succeeds. Acknowledge immediately and do the real work asynchronously.
  • Delivery is at-least-once. A handler that succeeds slowly can be retried, so the same job_id may arrive more than once. Make your handler idempotent — key on job_id.

We do not follow redirects. Point the URL at its final destination.

Webhook URL requirements

RequirementWhy
https:// onlyResults may contain sensitive output; plaintext isn’t acceptable.
Publicly resolvable hostHosts resolving to loopback, private, link-local or CGNAT ranges are rejected as SSRF targets — both when you save the URL and again at delivery time.
No credentials in the URLhttps://user:pass@host/… is rejected. Use the signing secret.
Responds within 3 sSee above.

Current limitations

Also note:

  • No polling endpoint. The callback is the only way to receive a result; there is no GET /jobs/{id}.
  • No streaming. stream: true in a chat body is ignored — async delivers one complete result.