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.

Embed
Preview of the card
<a href="https://scrapelyserp.com/reference/webhook-events"><img src="https://scrapelyserp.com/reference/_api/public/orgs/affc5b0f-d3c2-423a-8143-9d10db5736ae/spaces/e4cbe065-c1c2-4572-b134-0292dedd9c39/pages/d4cab2a0-1c9f-4669-adaa-0f21d68a0228/social-card.svg?v=3e7ae33eec8b" alt="Webhook events" width="600" height="315"></a>

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

id

string

The event id. The same on every retry (also Scrapely-Delivery): deduplicate on it.

type

string

The event (also Scrapely-Event). One of: watchlist.product.changed, watchlist.product.matched, watchlist.product.delisted, watchlist.run.completed, webhook.test.

created_at

string (date-time)

When the event was created (not when this attempt was sent).

data

object

The event's payload.

Headers

Header

Value

Scrapely-Signature

t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, keyed with the endpoint's secret. Two v1= while a rotation overlaps.

Scrapely-Event

The event type, as in the body's type.

Scrapely-Delivery

The event id (evt_…), the same on every retry: deduplicate on it.

Scrapely-Attempt

1 on the first try, then 2, 3, …

Scrapely-Endpoint

The id of the receiving endpoint (we_…): which secret to verify with.

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

  1. Read t and every v1 from Scrapely-Signature (t=1792000000,v1=5f2b…).

  2. Compute the hex HMAC-SHA256 of <t>.<raw body> with the endpoint's secret (whsec_…; Scrapely-Endpoint says which endpoint).

  3. Accept when it equals any v1, compared in constant time.

  4. Refuse a t more 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.

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 "", 200

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

watchlist_id

string

The list (wl_…).

watchlist_name

string

—

platform

string

—

marketplace

string

—

currency

string

The currency of every price in this payload.

run_id

string

The run (wlrun_…): GET /v1/watchlists/{id}/runs lists it.

run_number

integer

The run counter of the list, from 1.

part

integer

1-based; a run with many changes sends several deliveries.

parts

integer

How many deliveries this event was split into.

changes

object[]

At most 500 items.

changes[].id

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.

changes[].asin

string

Deprecated. Amazon lists only: the same value as id, kept for clients written before platforms. Absent on every other platform.

changes[].changed_at

string (date-time)

—

changes[].tier

string

The tier that fetched it (Amazon: lite or full; one-tier platforms: their one tier).

changes[].step

string

The pipeline step that saw the change.

changes[].scraper

string

That step's scraper.

changes[].changes

object

Only the fields that moved, each { before, after }. Each platform tracks its own fields; on Amazon: price, inStock, availabilityType, isFba, offerListingId, merchant, condition, newCount, newMinPrice, usedCount, usedMinPrice (merchant compared by id).

changes[].snapshot

object

The tracked fields from the last detail fetch, named as in /v1/products results (each platform tracks its own; these are Amazon's). null = the retailer did not say.

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

watchlist_id

string

The list (wl_…).

watchlist_name

string

—

platform

string

—

marketplace

string

—

currency

string

—

run_id

string

The run (wlrun_…): GET /v1/watchlists/{id}/runs lists it.

run_number

integer

The run counter of the list, from 1.

part

integer

—

parts

integer

How many deliveries this event was split into.

products

object[]

At most 500 items.

products[].id

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.

products[].asin

string

Deprecated. Amazon lists only: the same value as id, kept for clients written before platforms. Absent on every other platform.

products[].matched_at

string (date-time)

—

products[].step

string

—

products[].scraper

string

—

products[].branch

string

One of: new, changed, unchanged, ok, failed, not_found, always.

products[].status

string

—

products[].values

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

watchlist_id

string

The list (wl_…).

watchlist_name

string

—

platform

string

—

marketplace

string

—

currency

string

The currency of every price in this payload.

run_id

string

The run (wlrun_…): GET /v1/watchlists/{id}/runs lists it.

run_number

integer

The run counter of the list, from 1.

part

integer

—

parts

integer

How many deliveries this event was split into.

products

object[]

At most 500 items.

products[].id

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.

products[].asin

string

Deprecated. Amazon lists only: the same value as id, kept for clients written before platforms. Absent on every other platform.

products[].delisted_at

string (date-time)

—

products[].detected_by

string

The tier that saw it (Amazon: pulse, lite or full; one-tier platforms: their one tier, e.g. standard).

products[].step

string

—

products[].scraper

string

—

products[].last_snapshot

object or null

The tracked fields from the last detail fetch, named as in /v1/products results (each platform tracks its own; these are Amazon's). null = the retailer did not say.

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

id

string

—

object

"watchlist_run"

—

watchlist_id

string

—

run_number

integer

—

trigger

string

One of: schedule, manual.

kind

string

sweep: a regular run from the pipeline's entry step. refresh: every Nth run, from the refresh entry (refresh_every_runs). One of: sweep, refresh.

status

string

One of: running, completed, insufficient_credits, failed.

credits

integer

Charged, after refunds.

stats

object or null

—

stats.tracked

integer

—

stats.entry

string

The step the run started from.

stats.new_items

integer

Items read for the first time.

stats.steps

object

Per step that ran, in run order.

stats.matched

integer

Items a notify rule matched on a branch other than changed.

stats.sweep_tier

string

Deprecated. Runs before pipelines. Amazon: pulse, or lite on a refresh run. One-tier platforms: their one tier.

stats.calls

object

Deprecated. Runs before pipelines, keyed by tier.

stats.escalated

object

Deprecated.

stats.deferred

integer

Step items postponed to the next run: the balance could not cover them.

stats.changed

integer

—

stats.delisted

integer

—

stats.retry_next_run

integer

partial + unavailable items (free, retried next run, never a change).

stats.credits_needed

integer

On insufficient_credits: what the run needed up front.

error

string or null

—

started_at

string (date-time)

—

finished_at

string (date-time) or null

—

watchlist

object

—

watchlist.id

string

—

watchlist.name

string

—

watchlist.platform

string

—

watchlist.marketplace

string

—

watchlist.status

string

One of: active, paused.

watchlist.paused_reason

string or null

—

watchlist.next_run_at

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

message

string

—

endpoint_id

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"
  }
}
Updated