Stop Babysitting Your Agent Runs: A Quick Guide to TinyFish Webhooks

TL;DR
You know that itch to keep checking if something's done? Like refreshing for a text that hasn't landed yet, waiting for a YouTube ad to finish? With agent runs, you don't have to.
Meet webhook_url. Add one field to your run, point it at an endpoint, and TinyFish pushes the result to you the moment it's ready. One line of config instead of a polling loop you build, run, and babysit forever.
If you've ever fired off a TinyFish agent run and then written a little loop to keep checking "is it done yet?", this one's for you. Webhooks let you delete that loop entirely.
Here's the whole idea in one line: instead of you repeatedly asking TinyFish whether a run has finished, TinyFish tells you the moment it does by sending the full result straight to a URL you control.
The before: polling
Right now, the default way to find out when a run finishes is to ask, over and over, until the answer changes:
import time, requests
run_id = start_run() # kick off the agent
while True:
r = requests.get(f"https://agent.tinyfish.ai/v1/runs/{run_id}",
headers={"X-API-Key": API_KEY}).json()
if r["status"] in ("COMPLETED", "FAILED", "CANCELLED"):
break
time.sleep(5) # just... waiting
print(r["result"])This works. But it's annoying in a few specific ways:
- You're firing off dozens of requests just to hear "not yet, not yet, not yet" and it only gets worse once you're juggling several runs at once.
- You need something to keep that loop running: a background worker, a cron, a process that doesn't die. That's real infrastructure you now own.
- If your run takes 40 seconds and you poll every 5, that's ~8 wasted calls for 1 useful answer. Multiply that across thousands of runs and it adds up.
The after: webhooks
You add one field when you create the run, webhook_url , and you're done:
curl -X POST "https://agent.tinyfish.ai/v1/automation/run-async" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com",
"goal": "Extract the product name and price",
"webhook_url": "https://your-server.com/webhooks/tinyfish"
}'When the run hits a terminal state, COMPLETED, FAILED, or CANCELLED TinyFish sends a POST to your URL with the full run data. No loop. No babysitting.
The payload looks like this:
{
"event": "run.completed",
"run_id": "4562765d-156e-4c47-b217-55b3ec4a9720",
"status": "COMPLETED",
"data": {
"run_id": "4562765d-156e-4c47-b217-55b3ec4a9720",
"status": "COMPLETED",
"goal": "Extract the product name and price",
"created_at": "2026-03-25T10:30:00Z",
"started_at": "2026-03-25T10:30:05Z",
"finished_at": "2026-03-25T10:30:45Z",
"num_of_steps": 3,
"result": { "product_name": "Example Product", "price": 29.99 },
"error": null,
"steps": [
{
"id": "09de224d-fb2b-45b7-b067-585726194a2e",
"timestamp": "2026-03-25T10:30:05Z",
"status": "COMPLETED",
"action": "Navigate to https://example.com",
"screenshot": null,
"duration": "1.2s"
}
]
}
}You get the extracted result, the full step-by-step action log, and any error details if things went sideways.
(Screenshots aren't in the payload. That keeps it small. Need them? Call GET /v1/runs/{id}?screenshots=base64 after.)
When a run fails, the payload looks like this instead:
{
"event": "run.failed",
"run_id": "4562765d-156e-4c47-b217-55b3ec4a9720",
"status": "FAILED",
"data": {
"error": {
"message": "Site blocked access",
"category": "AGENT_FAILURE"
},
"result": null
}
}Handle run.completed, run.failed, and run.cancelled in the same place. The unhappy paths come through the same webhook, so you're not wiring up separate error channels.
A real use case: competitor price tracking โ Slack
Say you want to watch a competitor's pricing page every morning and get pinged in Slack the moment fresh data lands.
With polling, you'd schedule the run, then stand up a worker that wakes up, hammers the runs endpoint until the result appears, then forwards it to Slack. Two moving parts to maintain, and the worker has to stay alive.
With webhooks, the run is the trigger. You point webhook_url at a tiny endpoint that drops the result into Slack and calls it a day:
Python (Flask)
from flask import Flask, request
import requests
app = Flask(__name__)
@app.route("/webhooks/tinyfish", methods=["POST"])
def handle_webhook():
payload = request.json
event = payload["event"]
run_id = payload["run_id"]
data = payload["data"]
if event == "run.completed":
result = data["result"]
requests.post(SLACK_WEBHOOK_URL, json={
"text": f"๐ฐ {result['product_name']} is now {result['price']}"
})
elif event == "run.failed":
print(f"Run {run_id} failed:", data.get("error", {}).get("message"))
# respond fast โ TinyFish times out after 10s
return "OK", 200TypeScript (Express)
import express from "express";
const app = express();
app.use(express.json());
app.post("/webhooks/tinyfish", async (req, res) => {
const { event, run_id, data } = req.body;
switch (event) {
case "run.completed": {
const { product_name, price } = data.result;
await fetch(SLACK_WEBHOOK_URL, {
method: "POST",
body: JSON.stringify({ text: `๐ฐ ${product_name} is now ${price}` })
});
break;
}
case "run.failed":
console.error(`Run ${run_id} failed:`, data.error?.message);
break;
}
// respond fast โ TinyFish times out after 10s
res.status(200).send("OK");
});Schedule the run on a cron and you never touch it again. Agent finishes โ webhook fires โ the price lands in your Slack channel on its own. That unprompted ping is the whole payoff.
A couple of things worth knowing
Retry behavior
TinyFish retries failed webhook deliveries automatically:
| Behavior | Detail |
|---|---|
| Total attempts | 4 (1 initial + 3 retries) |
| Backoff | Exponential: 1s, 2s, 4s |
| Timeout | 10 seconds per attempt |
| 4xx responses | No retry, fails immediately |
| 5xx responses | Retried with backoff |
| Network errors | Retried with backoff |
The 4xx detail matters: if your endpoint returns a 400, TinyFish won't retry. Return a 2xx immediately to acknowledge, even if you're still processing.
A few more things to build on safely
- HTTPS only. The webhook_url has to be HTTPS.
- Respond fast, process slow. Acknowledge with a 200 immediately, then do any heavy lifting in the background. You have 10 seconds.
- Dedupe with run_id. In rare cases (retries, restarts) you might get the same webhook twice. Use run_id to make your handler idempotent:
seen_runs = set() # use Redis or a DB in prod
def handle_webhook():
run_id = payload["run_id"]
if run_id in seen_runs:
return "OK", 200 # already processed
seen_runs.add(run_id)
# ... rest of handler- It fires on failures too. run.failed and run.cancelled come through the same webhook, so you can handle all terminal states in one place.
- Verify for sensitive workflows. If you need extra confidence, call GET /v1/runs/{run_id} to confirm the result before acting on the payload.
Full payload reference and best practices are in the webhooks docs.
Stop polling. Start building.
TinyFish Web Agent ships with webhooks out of the box. 500 free credits to get started and no babysitting required.



