API Reference

Webhooks

Get notified the moment a run finishes instead of polling. Pass webhookUrl when you create a run and Photopipe POSTs a JSON payload to that URL once the run reaches a terminal status.

When events fire

Exactly one POST is sent per run, the first time the run reaches one of these terminal statuses:

  • completed— every item succeeded.
  • partial— some items succeeded, some failed.
  • failed— every item failed (or the run never produced any).
  • cancelled— the run was cancelled via the API or UI.

Subsequent status updates (e.g. cancelling an already-failed run) do not produce additional events.

Subscribing

Pass webhookUrl in the body of POST /workflows/:id/runs. The URL is stored on the run record and used for that run only — there's no global subscription concept; every run carries its own callback. The URL must use https:// and is capped at 1024 characters.

Create-run body excerpt
{
  "inputs": { "imageInput-1": { "files": [ /* ... */ ] } },
  "webhookUrl": "https://your-app.example.com/hooks/photopipe-run"
}

Request shape

Photopipe sends a JSON POST. The body is intentionally minimal — treat it as a wake-up signal and call back into the API (Get a run, Download outputs) for per-item details and signed download URLs.

Webhook request
POST https://your-app.example.com/hooks/photopipe-run
Content-Type: application/json
X-Photopipe-Event: run.finished
X-Photopipe-Run-Id: 9871

{
  "event": "run.finished",
  "runId": 9871,
  "workflowId": 123,
  "workspaceId": 42,
  "status": "completed",
  "totalItems": 2,
  "completedItems": 2,
  "failedItems": 0,
  "startedAt": "2026-04-29T14:05:01.000Z",
  "finishedAt": "2026-04-29T14:05:42.000Z",
  "error": null
}
Headers
Content-Type
string
Always application/json.
X-Photopipe-Event
string
Event name. Currently always run.finished — additional event types may be added later (existing receivers can ignore unknown values).
X-Photopipe-Run-Id
integer
Mirrors body.runId. Stable across retries — use this as your idempotency key.
Body
event
string
Currently always "run.finished".
runId
integer
Identifier of the finished run.
workflowId
integer
The workflow this run belongs to.
workspaceId
integer
Workspace owning the run. Useful when one receiver serves multiple workspaces.
status
"completed" | "partial" | "failed" | "cancelled"
The terminal status that triggered the event.
totalItems
integer
Total items dispatched for this run.
completedItems
integer
Items that finished successfully.
failedItems
integer
Items that failed.
startedAt
string | null
ISO-8601 timestamp the run started executing. Null for runs that failed before bootstrap.
finishedAt
string | null
ISO-8601 timestamp the run reached its terminal status.
error
string | null
High-level error string when status is failed. Per-item errors live on the items endpoint.

Responding

Return any 2xxstatus within 15 seconds to acknowledge the delivery. Anything else — non-2xx, timeout, DNS failure, TLS error — is treated as a failure and the message is re-queued.

We don't inspect the response body, so an empty 200 OK is fine. Keep your handler fast: do the minimum work needed to record the notification (e.g. enqueue an internal job) and return.

Retries & delivery guarantees

Delivery is at-least-once. Failed deliveries are retried with exponential backoff:

Default retry schedule
Attempt 1
immediate
Sent as soon as the run terminates.
Attempt 2
+1 minute
After the first failure.
Attempt 3
+5 minutes
Attempt 4
+30 minutes
Attempt 5
+3 hours
Attempt 6
+12 hours
Final attempt before the message is dropped to our dead-letter queue.

After the retry budget is exhausted the event is dropped and will not be retried. To recover from an extended outage, call Get a runfor any in-flight runs you remember dispatching — the run record holds the same status information indefinitely.

Because retries can land out of order with new events from unrelated runs, dedupe on X-Photopipe-Run-Id + status. Once you've processed an event for a given runId, ignore subsequent deliveries for the same id.

Authenticating the request

Photopipe currently does not sign webhook bodies. To verify the request is genuine, embed a secret token directly in your webhookUrl path or query string and check it on receipt:

Authenticated URL pattern
"webhookUrl": "https://your-app.example.com/hooks/photopipe-run?secret=YOUR_SHARED_SECRET"

Treat the URL itself as a credential — rotate it if you suspect it has leaked. HMAC body signing is on the roadmap.

Local development

Plain http:// URLs are rejected at create-run time, so a local Express server on http://localhost:3000 won't work directly. Use a tunneling tool such as ngrok or Cloudflare Tunnel to expose your local handler over HTTPS while developing.