Skip to main content
Runflow’s long-running endpoints — like Generate Headshots — don’t block the HTTP request. Instead, they return an immediate acknowledgement with a job ID, then POST the final result to a webhook URL that you provide. This page explains the callback payload, the expiry rules you need to respect, and shows a minimal handler you can copy-paste.

Why async?

Generating 60 professional AI headshots takes about 1–2 hours end-to-end. Holding an HTTP connection open that long is fragile (proxies time out, clients drop, retries re-trigger work). Webhooks decouple the slow work from the request/response cycle: Runflow acknowledges the job instantly, processes it in the background, then pushes the results to you when they’re ready.

Flow at a glance

  1. You call the async endpoint with a callback_url pointing at your webhook handler.
  2. Runflow validates the request and responds immediately with a headshots_id (or similar job ID).
  3. You save the job ID so you can match it to the callback later.
  4. Runflow runs the pipeline in the background.
  5. Runflow POSTs the final payload to your callback_url when done.
  6. You save the result URLs to your own storage immediately (they expire — see below) and return 200 OK.

Callback payload

For Generate Headshots, the callback body looks like:
{
  "event": "headshots.completed",
  "headshots_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "image_urls": [
    "https://cdn.runflow.io/headshots/out_001.jpg",
    "https://cdn.runflow.io/headshots/out_002.jpg",
    "..."
  ]
}
FieldTypeDescription
eventstringAlways headshots.completed for this endpoint. Use this to route on event type if you handle multiple.
headshots_idstring (UUID)Matches the headshots_id returned by the initial API call.
image_urlsstring[]60 generated headshot URLs. Expire after 24 hours.
The image_urls in the callback are time-limited presigned URLs that expire after 24 hours. Your webhook handler must download or re-upload them to your own storage (S3, GCS, your CDN, etc.) immediately on receipt — do not store the raw Runflow URLs in your database as the source of truth.

Handler example (Node.js / Express)

// Example Express.js webhook handler
app.post("/webhook/headshots", async (req, res) => {
  const { event, headshots_id, image_urls } = req.body;

  if (event === "headshots.completed") {
    // Store image_urls immediately — they expire in 24 hours
    await db.saveHeadshots({ headshots_id, image_urls });
  }

  res.sendStatus(200);
});
A production handler should also:
  • Respond fast. Acknowledge with 200 first, then do the heavy work (downloading, re-uploading to your storage) in a background job.
  • Be idempotent. If Runflow retries the callback, match on headshots_id and no-op if you’ve already processed it.
  • Validate the source. Put your webhook behind a secret path or verify a shared header so random callers can’t spoof callbacks.

Registering your callback URL

You don’t register webhooks separately — you pass callback_url as a field on each async endpoint call. That means you can use different callback URLs per job (useful for multi-tenant apps where each customer has their own handler) without any dashboard configuration.
curl -X POST https://api.runflow.io/api/v1/images/generate-headshots \
  -H "x-api-key: YOUR_API_KEY" \
  -F "callback_url=https://yourapp.com/webhook/headshots" \
  -F "images=@/path/to/photo1.jpg" \
  -F "images=@/path/to/photo2.jpg"

Local development

Your laptop isn’t reachable from the public internet, so Runflow can’t POST to http://localhost:3000. While building, use a tunneling tool like ngrok or Cloudflare Tunnel to expose your local server under a public URL, then pass that URL as callback_url.

What’s next

Generate Headshots reference

Full request/response schema and playground.

Vibe Coder Integration

Paste-ready AI-agent prompts that wire this up for you.