Webhooks
Asynchronous notifications when a scrape session finishes, instead of polling the API — subscribing, verifying delivery, and an n8n integration example.
Instead of polling GET /me/scrapes/{id} until status flips to completed, you can
subscribe a URL and we'll POST to it the moment that happens. One event exists today:
scrape_session.completed.
POST /webhooks endpoint to call yourself. Up to 5 active subscriptions per account.The payload is a pointer, not the data
{
"event": "scrape_session.completed",
"user_id": "3e2f9a10-df34-4b8a-9c31-8e1d2f6a7b90",
"scrape_session_id": "b1f8b6b0-2f7d-4a1a-9a3c-2f6d8e9a1234",
"status": "completed",
"stats": { "total": 40, "pending": 0, "processing": 0, "merged": 38, "rejected": 1, "flagged": 1 },
"url": "https://data.marketninja.ru/v1/me/scrapes/b1f8b6b0-2f7d-4a1a-9a3c-2f6d8e9a1234/products"
}We deliberately don't embed the scraped data in the event itself — GET the url field
(same Bearer auth as everything else on this site, and it's billed against your normal
daily quota, the same as calling that endpoint directly) to actually retrieve it. Two
reasons: your daily data quota is only ever enforced on that one endpoint, and a thin
payload means one webhook works the same whether the session had 1 item or 10,000.
user_id identifies which of your Market Ninja accounts the event belongs to — useful if
you ever point more than one account's webhooks at the same URL (a shared automation
endpoint, for instance) and need to tell them apart.
Verifying a delivery
Every request carries two headers:
X-Marketninja-Event: scrape_session.completedX-Marketninja-Signature: t=1735689600,v1=5257a869e7bcd1d2...
The signature is HMAC-SHA256 over ${timestamp}.${rawBody}, using the signing secret
shown once when you created the subscription. Verify it against the raw request body
string exactly as received — never against a re-serialized JSON.stringify(JSON.parse(body)),
since that isn't guaranteed to produce identical bytes (key ordering, whitespace) and will
silently break the comparison.
import crypto from "node:crypto";
function isValidSignature(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("="))
);
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(parts.v1),
Buffer.from(expected)
);
}t isn't too old (a few minutes' tolerance is typical) if you want replay protection — we don't enforce a tolerance on our side.Delivery, retries, and when we give up
- Any
2xxresponse is treated as delivered; the body isn't read. - We don't follow redirects — a
3xxis treated the same as a hard failure. - Each attempt times out after 5 seconds.
- A failed attempt (network error, timeout,
429, or5xx) is retried on a backoff schedule: roughly 1 min, 5 min, 30 min, 2h, 6h, then 24h later — up to 7 attempts total, spanning about a day and a half — before that specific delivery is marked permanently failed. A429with aRetry-Afterheader is honored instead of the fixed schedule. - Any other non-
2xx(e.g.400,404) is treated as permanent immediately — we assume retrying won't change your endpoint's mind. - If a subscription accumulates enough consecutive failed attempts, it's automatically disabled — there's no way to re-enable a disabled subscription, only create a new one from the extension's options page.
https:// address — we reject a target_url that resolves to a private/loopback/link-local address at subscription-creation time (and re-check before every delivery). Testing against a local server needs a tunnel (ngrok, Cloudflare Tunnel, etc.) first.Example: trigger an LLM pricing summary in n8n
A common shape: get notified, pull the data, hand it to an LLM, post the result somewhere. Two nodes do the Market-Ninja-specific part of this; everything after is entirely up to you.
-
A Webhook node as the trigger —
HTTP Method: POST, any path you choose. This is the URL you register as the subscription's target. -
An HTTP Request node right after it, configured to
GETthe incoming event'surlfield with Bearer authentication:- URL:
{{ $json.body.url }} - Authentication: Generic Credential Type → Bearer Auth, with a stored credential
holding one of your
mn_live_...API keys.
Its output is
{ data: [...], meta: {...} }— the same shapeGET /me/scrapes/{id}/productsalways returns, one entry per submission withproduct(nullfor anything not yet merged). - URL:
-
From there, do whatever you actually want with
$json.data— an LLM node summarizing pricing trends, a Slack/Telegram notification, a database write, or all three chained together. None of that is Market-Ninja-specific; wire it up like any other n8n workflow.
200 immediately by default, independent of whether the rest of your workflow succeeds. A "delivered" status on our side only confirms your Webhook node received the ping — check your own workflow's execution history to confirm the automation itself worked.Last updated on
Scrape sessions
Retrieving exactly what you just scraped via the extension — checking a session's status and pulling its data once it's merged
List brands GET
`marketplace` is optional here, unlike `GET /categories` — a brand is the same real-world entity across marketplaces, so an unscoped list (a brand's total footprint across every site) is meaningful, unlike an unscoped category list, which would mix unrelated per-site taxonomies together. This endpoint doesn't count against your daily data quota — only the per-minute rate limit. It's a lookup endpoint for navigation, not a data export, so it can never return `quota_exceeded`.