Receiving webhooks
Signed events, how to verify the signature, retries and deduplication.
We tell your system when something changes: an account connected, disconnected, a job finished. Each event arrives as a signed POST to the URL you register.
1. Register an endpoint
curl -X POST $BACKEND_URL/v1/webhooks/endpoints \
-H "Authorization: Bearer mk_your_api_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"url":"https://your-app.com/webhooks/marketapi","events":["*"]}'The 201 response carries the secret (whsec_...) only once. Keep it: you verify the signature with it. URL and event rules are in Register an endpoint.
2. What arrives
| Event | When | data fields |
|---|---|---|
connection.created | A seller connected a marketplace account. | connection, linkSessionId, syncJobId |
connection.updated | A connection changed: the seller connected the same account again, or it failed for good (`active` to `error`). | connection, previousStatus, syncJobId |
connection.revoked | A connection was revoked: by you, or by the marketplace (the seller removed the access, or the marketplace refused to renew it). | connection |
link_session.completed | A seller clicked Finish on a link session. | linkSession |
seller.created | A seller was created. | seller |
seller.updated | A seller's name or external ID changed. | seller |
seller.deleted | A seller was deleted. | seller |
product.created | A reusable product base was created. | product |
product.updated | A reusable product base changed locally. | product |
product.archived | A reusable product base was archived. | product |
variant.created | A sellable product variant was created. | variant |
variant.updated | A sellable product variant changed locally. | variant |
variant.archived | A sellable product variant was archived. | variant |
inventory.movement_created | An immutable inventory movement was recorded. | movement, balance, publicationJobId |
inventory.published | Current inventory quantity was published to a listing binding. | listingId, bindingId, variantId, quantity |
warehouse.created | A warehouse was created, by you or together with its seller (the default one). | warehouse |
warehouse.updated | A warehouse changed: its name, its external ID, whether it is the seller's default, or it was reactivated. | warehouse |
warehouse.archived | A warehouse was archived. | warehouse |
marketplace_product.created | A marketplace product (content shared by offers) was created locally or imported. | marketplaceProduct |
marketplace_product.updated | A marketplace product changed: its local content, its variations, or what the marketplace shows. | marketplaceProduct, behavior, jobId |
listing.created | A local marketplace listing draft was created. | listing |
listing.updated | A listing or its synchronization changed. | listing, behavior, jobId, bindingId |
listing.deleted | A local listing record was removed by an explicit choice. | listing, publication |
listing.status_changed | The marketplace status of a listing or of one of its lines changed. | listingId, connectionId, marketplaceProductId, source, listing, bindings |
listing.external_changed | A marketplace listing change was observed. | listing, behavior, recordUpdated, productId, variantIds, observed |
listing.conflict_created | A conflict was opened: the marketplace changed a group of the listing that also had local changes not yet published. | listingId, conflictId, group, paths |
listing.conflict_resolved | A listing conflict was resolved. Only the local desired data changed: nothing was published. | listingId, conflictId, group, paths, resolution |
asset.ready | An asset passed its checks and can be used (`ready`). | assetId, class, status |
asset.failed | An asset was refused or its upload was never completed (`failed`). Its file was discarded. | assetId, class, status, failureReason |
order.created | An order appeared in marketapi: by import, reconciliation or a marketplace notification. | order |
order.updated | An order changed: its status, totals, lines, payments or the link of a line. | order |
order.cancelled | An order was cancelled. This event is sent alone: the cancellation does not produce `order.updated`. | order |
job.completed | A job finished with every item done. | job |
job.partially_completed | A job finished with some items failed. | job |
job.failed | A job failed. | job |
Three details of data: in connection.updated, syncJobId is null when the change started no sync. In connection.revoked, the reason is in connection.revokedReason. And each final status of a job has its own event: job.completed (every item succeeded), job.partially_completed (some failed) and job.failed. In all of them data carries the whole job. See Tracking jobs.
For products and listings, product.* and variant.* describe local changes only; they do not publish to a marketplace. listing.external_changed always reports an external change: behavior: "applied" means only the listing's observed state was updated; behavior: "observed_only" means inbound was disabled and no record was changed. In both cases data.observed carries the normalized representation that was just fetched. An external deletion follows the same rule: with active inbound the observed status becomes deleted; it never deletes the local Listing.
listing.conflict_created and listing.conflict_resolved carry only ids, the group and the paths, never the values. Read both sides with List conflicts.
For inventory, inventory.movement_created is sent once per ledger row. data carries the movement (with warehouseId and transferId) and the Variant balance after the command, in total and per warehouse. The two events of a transfer carry the same balance. warehouse.created, warehouse.updated and warehouse.archived carry the warehouse in data.warehouse. When the default warehouse changes, the previous one also gets warehouse.updated.
Headers of each delivery:
| Header | Value |
|---|---|
X-Marketapi-Event | the type |
X-Marketapi-Delivery-Id | id of this delivery (changes on every redelivery) |
X-Marketapi-Signature | t=<unix seconds>,v1=<HMAC-SHA256> |
{
"id": "e1d2c3b4-a5f6-4e7d-8c9b-0a1b2c3d4e5f",
"type": "connection.created",
"createdAt": "2026-10-06T14:32:00.000Z",
"accountId": "8a2e5b90-1c44-4d7a-b3f2-5e6a7c8d9f02",
"sellerId": "3d1c8a52-7f0e-4b0a-8a65-9c1b2f6d7e01",
"data": {
"connection": {
"id": "9a1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d",
"marketplace": "mercadolivre",
"status": "active"
},
"linkSessionId": "5b2f0c1e-8d3a-4f6b-9c21-7e4d3a2b1c00",
"syncJobId": "7c6b5a49-3827-4615-a0b9-c8d7e6f5a4b3"
}
}(The example trims the connection fields; the full shape is in Get a connection.)
Answer 2xx within 10 seconds. Anything else counts as a failure — redirects (3xx) included: register the final URL.
3. Verify the signature
v1 is the HMAC-SHA256, in hex, of <t>.<body> with your secret (the whole value, whsec_ included). Use the raw body, as it arrived: re-serialized JSON changes the bytes and the signature will not match. Reject a t more than 5 minutes off.
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const SECRET = process.env.MARKETAPI_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_S = 5 * 60;
const seen = new Set(); // event ids already handled; use your database in production
const isValid = (header, rawBody) => {
const parts = Object.fromEntries(
header.split(',').map((part) => part.split('=')),
);
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > TOLERANCE_S) return false;
const expected = Buffer.from(
createHmac('sha256', SECRET).update(`${t}.${rawBody}`).digest('hex'),
);
const received = Buffer.from(parts.v1 ?? '');
return (
expected.length === received.length && timingSafeEqual(expected, received)
);
};
const app = express();
app.post(
'/webhooks/marketapi',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody = req.body.toString('utf8');
if (!isValid(req.get('x-marketapi-signature') ?? '', rawBody))
return res.sendStatus(400);
const event = JSON.parse(rawBody);
if (!seen.has(event.id)) {
seen.add(event.id);
// Act on event.type and event.data here.
}
res.sendStatus(200);
},
);
app.listen(3000);The example uses express and is ESM: "type": "module" in package.json (or save it as .mjs).
Rotation
If the secret leaks or gets lost, rotate it. The new one shows up only once, and the old one stops working at once, with no overlap period. Update your system right after.
4. Retries and deduplication
If a delivery fails, we try again after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h. After the 8th attempt the delivery is failed.
The same event can arrive more than once (you answered late, or asked for a redelivery). Deduplicate on the event id; X-Marketapi-Delivery-Id changes on every delivery.
5. Log and redelivery
The log shows each delivery with the outcome of its last attempt: List deliveries. Fixed your endpoint? Redeliver sends the event again, as a new delivery. The dashboard has the same Redeliver button.