Skip to main content
Agents and task runs are asynchronous. When you call run_task or run_workflow, the API returns immediately with a run ID, but the actual execution happens in the background and can take variable time. Instead of polling the get_runs endpoint, you can use Webhooks to get notified when they finish. Webhooks fire when a run reaches a terminal status: completed, failed, terminated, timed_out, or canceled. A workflow with a retry policy sends one webhook after the final attempt by default. This page covers setting them up, explains payload structure, signature verification, and handling delivery failures.

Step 1: Set webhook URL

For tasks

For agents

Set a default webhook when creating the agent, or override it per-run:
When creating an agent, use webhook_callback_url inside json_definition; this sets the default for all runs. When running an agent, use webhook_url at the top level to override for that specific run.
Quick reference:
Watch the parameter names. Using webhook_url when creating an agent (instead of webhook_callback_url inside json_definition) silently results in no webhook being sent. The API won’t return an error. Your runs will just complete without notifications.

Workflow retries and webhooks

Add retry_policy to a workflow definition to retry selected terminal outcomes. Skyvern keeps the same workflow_run_id and records each execution in attempts[].
final_only is the default. Skyvern sends one webhook after the last attempt. Set webhook_on_retry to every_attempt to receive a webhook after each attempt. Each payload includes the attempt number and retry state. Every attempt shares the same run_id. Use run_id, attempt, and retry_pending together as the idempotency key, not run_id alone, or your receiver discards the final attempt as a duplicate of the first. A run that is canceled during its retry delay keeps the terminal status of its last attempt, for example failed, and sends a second webhook for the same attempt: the first payload has retry_pending: true, the second has retry_pending: false and the same status. A receiver that keys on run_id and attempt alone discards that final payload. Webhook delivery is recorded after delivery. If a worker crashes in that window, Temporal or OSS recovery waits for the bounded delivery grace period and retries while the timestamp is NULL, so endpoints must tolerate duplicates in that crash window. Runs without a webhook URL record completion as “nothing to deliver” and are not retried forever. An owner stops accepting its lease 60 seconds before recovery may take it over, leaving that margin as an intentional process-pause window before the next owner proceeds. If a worker pauses after the final webhook step starts, recovery may deliver that webhook again. Webhook consumers should tolerate duplicates. The final workflow hook runs once per logical run under the same lease contract, except that a process paused across a takeover may observe a second call if it resumes between the fresh ownership check and the hook call. When retry_pending is true, the run can still change its final result. Wait for retry_pending: false before processing the run as final.

Step 2: Understand the payload

Skyvern sends a JSON payload with run results. Here’s a real example from a completed task: Webhook Payload:
Request Headers Sent:

Optional: Verify webhook signatures

Skyvern signs every webhook and TOTP request with your API key using HMAC-SHA256, so you can verify the request actually came from Skyvern before acting on it. Headers sent with every request:
  • x-skyvern-signature: HMAC-SHA256 signature of the payload, hex-encoded
  • x-skyvern-timestamp: Unix timestamp when the request was sent
  • Content-Type: application/json
Copy the verifier for your language and reject the request whenever it returns false.
If you copied a verifier from this page before August 2026, re-check it. Earlier versions of these examples were permissive: the TypeScript example called crypto.timingSafeEqual() without using its return value, so any signature of the right length was accepted, and the TOTP examples compared signatures with ==. Replace them with the verifiers below.
Act on the comparison result, and reject a missing signature. A constant-time comparison only protects you if you branch on what it returns. Two ways to get this wrong: calling crypto.timingSafeEqual() for its side effects and treating “it did not throw” as success — it only throws on a length mismatch, so every wrong-but-64-character signature passes — or computing a verdict and never checking it. A request that arrives with no x-skyvern-signature header at all must be rejected, not crash your handler.
Use constant-time comparison to prevent timing attacks:
  • Python: hmac.compare_digest()
  • TypeScript: crypto.timingSafeEqual()
  • Go: hmac.Equal()
Never use simple equality operators (== or ===) for signature comparison as they are vulnerable to timing attacks.
Always validate against the raw request body bytes. Skyvern normalizes JSON before signing: it removes whitespace (using compact separators) and converts whole-number floats to integers (3.0 becomes 3). If you parse the JSON and re-serialize it, the byte representation will differ and signature validation will fail.

Handling webhook failures

Task execution and webhook delivery are independent; a task can succeed while webhook delivery fails. When this happens, the run shows status: "failed" even though your data was extracted successfully. Webhook delivery can fail due to network issues, server errors, or misconfigured URLs. When this happens, the run is marked as failed and the error is recorded in the failure_reason field. Check it by calling get_run after the run terminates:
The failure_reason field contains the specific error message, for example:
Even when webhook delivery fails, the task’s output field may still contain extracted data if the browser automation completed successfully before the webhook attempt.
Common reasons webhooks fail:
  • Server unreachable: Your server is down, behind a firewall, or the URL is incorrect. Verify the URL is publicly accessible (not localhost) and check your server logs for incoming requests.
  • Timeout: Skyvern waits 10 seconds for a response. If your server takes longer, the delivery is marked as failed even if processing eventually succeeds. Return 200 OK immediately and process the payload in a background job.
  • Server returns an error: Your endpoint received the payload but responded with a non-2xx status code (e.g., 500). Check your server logs to identify the issue.
  • Signature validation fails: If your verification logic rejects the request, make sure you’re validating against the raw request body, not parsed-and-re-serialized JSON (re-serializing changes the byte representation). Also verify you’re using the same API key that created the run.
Recommended pattern: Always have a fallback polling mechanism for critical agents. If you don’t receive a webhook within your expected window, call get_run to check if the run completed and retrieve the data directly.

Replaying webhooks

Once you’ve identified and fixed the issue, you can replay the webhook using retry_run_webhook.
Workflow retry policies retry workflow execution, not webhook delivery. Webhook delivery remains a separate operation. You must explicitly call retry_run_webhook after fixing a delivery issue. The endpoint returns HTTP 409 Conflict while a workflow retry is pending.
retry_run_webhook is fire-and-forget; it returns immediately without waiting for delivery confirmation. To verify success, monitor your webhook endpoint directly or check the run’s failure_reason field after a short delay.
Implement idempotency. If you call retry_run_webhook, you may receive the same payload twice (once from the original attempt that your server processed but returned an error, and once from the retry). Use run_id, attempt, and retry_pending together as the idempotency key: check if you’ve already processed this attempt in this retry state before taking action. Do not key on run_id alone. When webhook_on_retry is every_attempt, every attempt shares one run_id, and a receiver that keys on run_id alone discards the final attempt. A run canceled during its retry delay keeps the terminal status of its last attempt and sends two payloads for the same attempt, first with retry_pending: true and then with retry_pending: false, so a receiver that keys on run_id and attempt alone discards the final one.
You can pass a different webhook_url to send the payload to a new endpoint; useful if the original URL was misconfigured.

Next steps

Error Handling

Handle failures and map custom error codes

Reliability Tips

Write robust prompts and add validation blocks