Send Apify run results to a webhook
Apify can fire an HTTP request at your server every time an actor run finishes. Wire it up once and your scrape results flow straight into Slack, a Google Sheet, S3, or whatever else you run — no polling, no cron. This guide also covers how the receiver verifies the request really came from Apify, since Apify does not HMAC-sign webhook payloads.
Use with an AI agent
Open this guide as a pre-filled prompt — or copy it for Claude Code, Cursor, Codex, or any other coding agent.
What you'll set up
When an actor run hits a terminal state, Apify sends an HTTP POST to a URL you control. The default event you want is ACTOR.RUN.SUCCEEDED. The flow:
- Your actor finishes a run.
- Apify POSTs a small JSON envelope to your
requestUrl— it includes theactorRunIdanddefaultDatasetId, not the dataset items themselves. - Your server verifies the request, fetches the dataset via the Apify API, and does something with it (post to Slack, append to a Sheet, drop into S3).
Two pieces of vocabulary worth keeping straight: events are things that happen on the platform (e.g. ACTOR.RUN.SUCCEEDED, ACTOR.RUN.FAILED), and webhooks are subscriptions that turn one of those events into an HTTP request to your URL.
Configure the webhook on Apify
There are two places to set this up. Pick one — they don't stack.
Option A — Console UI
Open your actor in the Apify Console, click the Webhooks tab, then Add webhook:
- Event types — at minimum, tick
ACTOR.RUN.SUCCEEDED. AddACTOR.RUN.FAILEDif you want to be paged when runs blow up. - Request URL — your public HTTPS endpoint (e.g.
https://your-server.example.com/apify-webhook). - Headers template — a JSON object of headers to send. Use this for your shared secret, e.g.
{"X-Webhook-Secret": "{{secrets.WEBHOOK_SECRET}}"}. DefineWEBHOOK_SECRETin Account → Integrations → Secrets so it doesn't sit in the webhook config in plaintext. - Payload template — leave it on the default unless you have a strong reason. The default envelope is plenty.
Option B — .actor/actor.json
If you want the webhook to ship with the actor build (so every install of the actor calls your server), declare it in .actor/actor.json:
{
"actorSpecification": 1,
"name": "my-actor",
"title": "My Actor",
"version": "0.1",
"webhooks": [
{
"eventTypes": ["ACTOR.RUN.SUCCEEDED"],
"requestUrl": "https://your-server.example.com/apify-webhook",
"headersTemplate": "{\"X-Webhook-Secret\": \"{{secrets.WEBHOOK_SECRET}}\"}",
"payloadTemplate": "{\"runId\":{{resource.id}},\"datasetId\":{{resource.defaultDatasetId}},\"status\":{{resource.status}}}"
}
]
}The {{secrets.WEBHOOK_SECRET}} placeholder is resolved against the secrets configured in the actor owner's account. Without a secret, anyone who guesses your URL can forge “run finished” calls.
The payload shape
With the default payloadTemplate, Apify POSTs a JSON envelope that looks like this:
{
"userId": "abc123",
"createdAt": "2026-05-15T08:00:00.000Z",
"eventType": "ACTOR.RUN.SUCCEEDED",
"eventData": {
"actorId": "your-actor-id",
"actorRunId": "AbCdEfGhIjKlMn"
},
"resource": {
"id": "AbCdEfGhIjKlMn",
"actId": "your-actor-id",
"userId": "abc123",
"startedAt": "2026-05-15T07:58:00.000Z",
"finishedAt": "2026-05-15T07:59:54.000Z",
"status": "SUCCEEDED",
"defaultKeyValueStoreId": "...",
"defaultDatasetId": "datasetIdXYZ",
"defaultRequestQueueId": "..."
}
}The two fields you actually use most:
eventData.actorRunId— your dedup key.resource.defaultDatasetId— the dataset to read items from.
Verify the request
Apify does not HMAC-sign webhook payloads. That's a notable gap compared to Stripe or GitHub, and it means verification is your responsibility. The standard approach: configure a custom header on the webhook with a shared secret, then check it on the receiver. Reject anything that doesn't match.
import express from 'express';
import { ApifyClient } from 'apify-client';
const app = express();
app.use(express.json());
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
const APIFY_TOKEN = process.env.APIFY_TOKEN;
const client = new ApifyClient({ token: APIFY_TOKEN });
// In-memory dedup. Use Redis or a DB in production.
const seen = new Set();
app.post('/apify-webhook', async (req, res) => {
// 1. Verify the shared secret.
if (req.get('X-Webhook-Secret') !== WEBHOOK_SECRET) {
return res.status(401).send('unauthorized');
}
// 2. Idempotency — Apify retries on 5xx.
const runId = req.body.eventData?.actorRunId;
if (seen.has(runId)) return res.status(200).send('already processed');
seen.add(runId);
// 3. Acknowledge fast, then do the work async.
res.status(200).send('ok');
// 4. Pull the dataset and deliver it.
const datasetId = req.body.resource?.defaultDatasetId;
const { items } = await client.dataset(datasetId).listItems();
await deliver(items);
});
async function deliver(items) {
// ...post to Slack / append to Google Sheets / upload to S3...
}
app.listen(3000);
import os
from apify_client import ApifyClient
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
WEBHOOK_SECRET = os.environ['WEBHOOK_SECRET']
client = ApifyClient(token=os.environ['APIFY_TOKEN'])
# In-memory dedup. Use Redis or a DB in production.
seen: set[str] = set()
@app.post('/apify-webhook')
async def apify_webhook(
request: Request,
x_webhook_secret: str | None = Header(default=None),
):
# 1. Verify the shared secret.
if x_webhook_secret != WEBHOOK_SECRET:
raise HTTPException(status_code=401, detail='unauthorized')
body = await request.json()
run_id = body.get('eventData', {}).get('actorRunId')
# 2. Idempotency — Apify retries on 5xx.
if run_id in seen:
return {'status': 'already processed'}
seen.add(run_id)
# 3. Pull the dataset and deliver it.
dataset_id = body.get('resource', {}).get('defaultDatasetId')
items = client.dataset(dataset_id).list_items().items
await deliver(items)
return {'status': 'ok'}
async def deliver(items: list[dict]) -> None:
"""Post to Slack / append to Google Sheets / upload to S3."""
...
Use a long, random secret (32+ bytes from a CSPRNG). Rotate it the same way you'd rotate any other API key.
Read the dataset
Once you've verified the request, pull the items via the Apify API. The apify-client SDKs above already do this:
- JS:
client.dataset(datasetId).listItems()returns{ items, total, offset, ... }. - Python:
client.dataset(dataset_id).list_items().itemsreturns the list directly.
For datasets larger than a few thousand rows, paginate with offset + limit, or stream items with iterateItems() (JS) / iterate_items()(Python) so you don't load the whole thing into memory.
Common destinations
Slack
Create an incoming webhook for the channel you want to post into, then drop one fetch into your deliver function:
// Slack incoming webhook URL — anyone with it can post.
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `New scrape: ${items.length} rows. First URL: ${items[0]?.url}`,
}),
});Google Sheets
Append rows via the Sheets API ( spreadsheets.values.append with valueInputOption=RAW). The official quickstarts cover auth and the request shape — see the Node.js quickstart and the Python quickstart. Map your dataset items to a row array and ship them in batches.
S3 / R2
For an audit-log-style destination, write the whole dataset as a single JSON snapshot keyed by runId:
// AWS S3 (R2 uses the same SDK with a custom endpoint).
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({ region: 'auto' });
await s3.send(new PutObjectCommand({
Bucket: process.env.SNAPSHOT_BUCKET,
Key: `runs/${runId}.json`,
Body: JSON.stringify(items),
ContentType: 'application/json',
}));Retries and idempotency
Apify retries failed webhooks (any non-2xx response, or a timeout) with exponential backoff. That's great for reliability, but it means your receiver has to be idempotent — the same actorRunId may arrive more than once, even after you returned 200.
Use eventData.actorRunId as your dedup key. The examples above use an in-memory Set; in production, store it in Redis with a short TTL or a unique constraint in your database.
Gotchas worth knowing
- Apify does not HMAC-sign payloads. Verification is up to you — set a custom header on the webhook config (using
{{secrets.WEBHOOK_SECRET}}or a hardcoded string) and check it on the receiver. Don't trust the request just because it parses. - Acknowledge fast. Apify will retry if your receiver takes too long or returns 5xx. Return
200immediately and do heavy work async, or push the run onto a queue. - Retries can duplicate. Even with a
200response, a network blip can cause Apify to retry. Always dedup onactorRunId. localhostwon't work. During development use a tunnel (ngrok, cloudflared). The webhook fires from Apify's network — your laptop isn't reachable.- Payload size is bounded. The dataset is not in the payload — only the dataset id. You must fetch the items yourself via the API. This is good — it lets you stream large datasets instead of POSTing them.
- Per-actor webhooks ship in the build. If you put webhooks in
.actor/actor.json, every run of that actor fires them. For per-user routing, configure webhooks dynamically via the API or have users set their own in the Console.
Where to go next
- Chain Apify actors together — use a webhook to kick off the next actor in a pipeline.
- Schedule your Apify actor — pair scheduled runs with webhook delivery for a fully hands-off pipeline.
- How to tell if an Apify user is paying — gate which runs trigger external delivery based on the user's plan.
Spotted a bug, or want a guide on something else?
support@mail.apifyhub.com