Webhook events
What Scrapely POSTs to your webhook endpoints: the envelope and headers every delivery shares, each event's payload with an example, and how to verify the signature.
What Scrapely POSTs to your webhook endpoints: the envelope and headers every delivery shares, each event's payload with an example, and how to verify the signature.
Every delivery
A POST with a JSON body, to one of your webhook endpoints. The body is an envelope:
Field | Type | Description |
|---|---|---|
| string | The event id. The same on every retry (also |
| string | The event (also |
| string (date-time) | When the event was created (not when this attempt was sent). |
| object | The event's payload. |
Headers
Header | Value |
|---|---|
|
|
| The event type, as in the body's |
| The event id ( |
| 1 on the first try, then 2, 3, … |
| The id of the receiving endpoint ( |
Answer any 2xx within 10 seconds, then do the work. Anything else (another status, a timeout, a redirect: redirects are never followed) is retried after 10 s, 1 min, 5 min, 30 min and 2 h, with the same id and an increasing Scrapely-Attempt. Delivery is at least once: deduplicate on Scrapely-Delivery.
Verifying signatures
Read
tand everyv1fromScrapely-Signature(t=1792000000,v1=5f2b…).Compute the hex HMAC-SHA256 of
<t>.<raw body>with the endpoint's secret (whsec_…;Scrapely-Endpointsays which endpoint).Accept when it equals any
v1, compared in constant time.Refuse a
tmore than 5 minutes away from now, so a captured delivery cannot be replayed.
Use the raw body
Verify the exact bytes you received, before parsing JSON. Re-serialising changes whitespace and the signature will not match.
- Python
- Node.js
- Go
- PHP
import hashlib
import hmac
import os
import time
def verify_scrapely(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
t, sigs = 0, []
for part in header.split(","):
k, _, v = part.strip().partition("=")
if k == "t":
t = int(v or 0)
elif k == "v1":
sigs.append(v)
if not t or abs(time.time() - t) > tolerance:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# Two v1 values while a rotation overlaps: either may match.
return any(hmac.compare_digest(expected, s) for s in sigs)
# Flask
@app.post("/hooks/scrapely")
def scrapely_hook():
secret = os.environ["SCRAPELY_WEBHOOK_SECRET"]
if not verify_scrapely(request.get_data(), request.headers.get("Scrapely-Signature", ""), secret):
abort(400)
enqueue(request.get_json()) # work after acknowledging
return "", 200import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyScrapely(rawBody, header, secret, toleranceSec = 300) {
let t = 0;
const sigs = [];
for (const part of header.split(',')) {
const [k, v = ''] = part.trim().split('=');
if (k === 't') t = Number(v);
else if (k === 'v1') sigs.push(Buffer.from(v, 'hex'));
}
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
// Two v1 values while a rotation overlaps: either may match.
return sigs.some((s) => s.length === expected.length && timingSafeEqual(s, expected));
}
// Express: keep the raw body for this route.
app.post('/hooks/scrapely', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!verifyScrapely(raw, req.get('Scrapely-Signature') ?? '', process.env.SCRAPELY_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
res.sendStatus(200);
queue.add(JSON.parse(raw)); // work after acknowledging
});func VerifyScrapely(rawBody []byte, header, secret string, tolerance time.Duration) bool {
var ts int64
var sigs []string
for _, p := range strings.Split(header, ",") {
k, v, _ := strings.Cut(strings.TrimSpace(p), "=")
switch k {
case "t":
ts, _ = strconv.ParseInt(v, 10, 64)
case "v1":
sigs = append(sigs, v)
}
}
if ts == 0 || time.Since(time.Unix(ts, 0)).Abs() > tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%d.", ts)
mac.Write(rawBody)
expected := mac.Sum(nil)
// Two v1 values while a rotation overlaps: either may match.
for _, sig := range sigs {
if given, err := hex.DecodeString(sig); err == nil && hmac.Equal(given, expected) {
return true
}
}
return false
}function verify_scrapely(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
$t = 0;
$sigs = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
if ($k === 't') $t = (int) $v;
elseif ($k === 'v1') $sigs[] = $v;
}
if (!$t || abs(time() - $t) > $tolerance) return false;
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
// Two v1 values while a rotation overlaps: either may match.
foreach ($sigs as $sig) if (hash_equals($expected, $sig)) return true;
return false;
}
$ok = verify_scrapely(file_get_contents('php://input'), $_SERVER['HTTP_SCRAPELY_SIGNATURE'] ?? '', getenv('SCRAPELY_WEBHOOK_SECRET'));During a secret rotation with overlap, deliveries carry two v1= values for 24 hours, one per secret; the samples accept either. Rotate with POST /v1/webhook_endpoints/{id}/rotate.
watchlist.product.changed
Watchlist products changed. A notify rule fired on a change: tracked fields moved on one or more items in a run. Each change names the step and scraper that saw it. Each entry carries id (plus asin on Amazon lists). At most 500 products per delivery; part/parts number them. Same envelope ({ id, type, created_at, data }), signature and retries as every other webhook. Sent to the list's webhook_url endpoint, else to every endpoint subscribed to the event.
Payload (data)
Field | Type | Description |
|---|---|---|
| string | The list ( |
| string | — |
| string | — |
| string | — |
| string | The currency of every price in this payload. |
| string | The run ( |
| integer | The run counter of the list, from 1. |
| integer | 1-based; a run with many changes sends several deliveries. |
| integer | How many deliveries this event was split into. |
| object[] | At most 500 items. |
| string | The item's id on the list's platform: an ASIN on Amazon, an item id on Walmart, a TCIN on Target, a SKU on Best Buy. |
| string | Deprecated. Amazon lists only: the same value as |
| string (date-time) | — |
| string | The tier that fetched it (Amazon: |
| string | The pipeline step that saw the change. |
| string | That step's scraper. |
| object | Only the fields that moved, each |
| object | The tracked fields from the last detail fetch, named as in |
Example delivery
{
"id": "evt_1a2b3c4d5e6f7a8b9c0d",
"type": "watchlist.product.changed",
"created_at": "2026-10-16T09:15:04.120Z",
"data": {
"watchlist_id": "wl_3f1c2a9e8d4b4c61",
"watchlist_name": "Consoles",
"platform": "target",
"marketplace": "us",
"currency": "USD",
"run_id": "wlrun_5f0c8a1b2d3e4f56",
"run_number": 42,
"part": 1,
"parts": 1,
"changes": [
{
"id": "83887639",
"changed_at": "2026-10-16T09:15:01.870Z",
"tier": "standard",
"step": "check",
"scraper": "target.standard",
"changes": {
"price": {
"before": 399.99,
"after": 349.99
}
},
"snapshot": {
"price": 349.99,
"regularPrice": 399.99,
"inStock": true,
"shipping": "IN_STOCK",
"pickup": "IN_STOCK",
"title": "Nintendo Switch - OLED Model with White Joy-Con",
"parentTcin": null
}
}
]
}
}watchlist.product.matched
Watchlist products matched. A notify rule fired on a branch other than changed (a value test, a failure, always…): the step, scraper, branch and the values it saw. At most 500 products per delivery. Same envelope ({ id, type, created_at, data }), signature and retries as every other webhook. Sent to the list's webhook_url endpoint, else to every endpoint subscribed to the event.
Payload (data)
Field | Type | Description |
|---|---|---|
| string | The list ( |
| string | — |
| string | — |
| string | — |
| string | — |
| string | The run ( |
| integer | The run counter of the list, from 1. |
| integer | — |
| integer | How many deliveries this event was split into. |
| object[] | At most 500 items. |
| string | The item's id on the list's platform: an ASIN on Amazon, an item id on Walmart, a TCIN on Target, a SKU on Best Buy. |
| string | Deprecated. Amazon lists only: the same value as |
| string (date-time) | — |
| string | — |
| string | — |
| string | One of: |
| string | — |
| object or null | What the step saw (its scraper fields). |
Example delivery
{
"id": "evt_2b3c4d5e6f7a8b9c0d1e",
"type": "watchlist.product.matched",
"created_at": "2026-10-16T09:15:04.120Z",
"data": {
"watchlist_id": "wl_7c2e9a1b3d4f5e60",
"watchlist_name": "Under $40",
"platform": "walmart",
"marketplace": "us",
"currency": "USD",
"run_id": "wlrun_2a3b4c5d6e7f8091",
"run_number": 12,
"part": 1,
"parts": 1,
"products": [
{
"id": "5129928603",
"matched_at": "2026-10-16T09:15:03.512Z",
"step": "check",
"scraper": "walmart.standard",
"branch": "ok",
"status": "ok",
"values": {
"price": 34.97,
"listPrice": 39.99,
"inStock": true,
"seller": "Kitchen Outlet LLC",
"byPlatform": false
}
}
]
}
}watchlist.product.delisted
Watchlist products delisted. The retailer answered not_found for these items. They stay on the list as delisted and are not fetched again. partial/unavailable never delist. Each entry carries id (plus asin on Amazon lists). Same envelope ({ id, type, created_at, data }), signature and retries as every other webhook. Sent to the list's webhook_url endpoint, else to every endpoint subscribed to the event.
Payload (data)
Field | Type | Description |
|---|---|---|
| string | The list ( |
| string | — |
| string | — |
| string | — |
| string | The currency of every price in this payload. |
| string | The run ( |
| integer | The run counter of the list, from 1. |
| integer | — |
| integer | How many deliveries this event was split into. |
| object[] | At most 500 items. |
| string | The item's id on the list's platform: an ASIN on Amazon, an item id on Walmart, a TCIN on Target, a SKU on Best Buy. |
| string | Deprecated. Amazon lists only: the same value as |
| string (date-time) | — |
| string | The tier that saw it (Amazon: |
| string | — |
| string | — |
| object or null | The tracked fields from the last detail fetch, named as in |
Example delivery
{
"id": "evt_3c4d5e6f7a8b9c0d1e2f",
"type": "watchlist.product.delisted",
"created_at": "2026-10-16T09:15:04.120Z",
"data": {
"watchlist_id": "wl_5e6f7a8b9c0d1e2f",
"watchlist_name": "Headphones",
"platform": "bestbuy",
"marketplace": "us",
"currency": "USD",
"run_id": "wlrun_9f8e7d6c5b4a3921",
"run_number": 31,
"part": 1,
"parts": 1,
"products": [
{
"id": "6418599",
"delisted_at": "2026-10-16T09:15:01.002Z",
"detected_by": "standard",
"step": "check",
"scraper": "bestbuy.standard",
"last_snapshot": {
"price": 129.99,
"regularPrice": 149.99,
"inStock": false,
"state": "sold_out",
"purchasable": false
}
}
]
}
}watchlist.run.completed
Watchlist run completed. Every run that ends: completed, insufficient_credits (nothing fetched or charged; watchlist.status shows an auto-pause) or failed. Same envelope ({ id, type, created_at, data }), signature and retries as every other webhook. Sent to the list's webhook_url endpoint, else to every endpoint subscribed to the event.
Payload (data)
Field | Type | Description |
|---|---|---|
| string | — |
| "watchlist_run" | — |
| string | — |
| integer | — |
| string | One of: |
| string |
|
| string | One of: |
| integer | Charged, after refunds. |
| object or null | — |
| integer | — |
| string | The step the run started from. |
| integer | Items read for the first time. |
| object | Per step that ran, in run order. |
| integer | Items a notify rule matched on a branch other than |
| string | Deprecated. Runs before pipelines. Amazon: |
| object | Deprecated. Runs before pipelines, keyed by tier. |
| object | Deprecated. |
| integer | Step items postponed to the next run: the balance could not cover them. |
| integer | — |
| integer | — |
| integer | partial + unavailable items (free, retried next run, never a change). |
| integer | On |
| string or null | — |
| string (date-time) | — |
| string (date-time) or null | — |
| object | — |
| string | — |
| string | — |
| string | — |
| string | — |
| string | One of: |
| string or null | — |
| string (date-time) or null | — |
Example delivery
{
"id": "evt_4d5e6f7a8b9c0d1e2f3a",
"type": "watchlist.run.completed",
"created_at": "2026-10-16T09:15:04.120Z",
"data": {
"id": "wlrun_5f0c8a1b2d3e4f56",
"object": "watchlist_run",
"watchlist_id": "wl_3f1c2a9e8d4b4c61",
"run_number": 42,
"trigger": "schedule",
"kind": "sweep",
"status": "completed",
"credits": 2,
"stats": {
"tracked": 2,
"entry": "check",
"new_items": 0,
"steps": {
"check": {
"scraper": "target.standard",
"requested": 2,
"ok": 2,
"partial": 0,
"not_found": 0,
"unavailable": 0,
"credits": 2,
"skipped": 0
}
},
"matched": 0,
"deferred": 0,
"changed": 1,
"delisted": 0,
"retry_next_run": 0
},
"error": null,
"started_at": "2026-10-16T09:15:00.004Z",
"finished_at": "2026-10-16T09:15:02.310Z",
"watchlist": {
"id": "wl_3f1c2a9e8d4b4c61",
"name": "Consoles",
"platform": "target",
"marketplace": "us",
"status": "active",
"paused_reason": null,
"next_run_at": "2026-10-16T09:30:00.004Z"
}
}
}webhook.test
Test delivery. Sent only by POST /v1/webhook_endpoints/{id}/test (or Send test in the dashboard), to that endpoint, once and without retries. Signed exactly like every other event, so it proves your verification code end to end.
Payload (data)
Field | Type | Description |
|---|---|---|
| string | — |
| string | The endpoint being tested. |
Example delivery
{
"id": "evt_5e6f7a8b9c0d1e2f3a4b",
"type": "webhook.test",
"created_at": "2026-10-16T09:15:04.120Z",
"data": {
"message": "Test delivery from Scrapely.",
"endpoint_id": "we_7b1c2d3e4f5a6b7c8d9e"
}
}