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

EventWhendata fields
connection.createdA seller connected a marketplace account.connection, linkSessionId, syncJobId
connection.updatedA connection changed: the seller connected the same account again, or it failed for good (`active` to `error`).connection, previousStatus, syncJobId
connection.revokedA connection was revoked: by you, or by the marketplace (the seller removed the access, or the marketplace refused to renew it).connection
link_session.completedA seller clicked Finish on a link session.linkSession
seller.createdA seller was created.seller
seller.updatedA seller's name or external ID changed.seller
seller.deletedA seller was deleted.seller
product.createdA reusable product base was created.product
product.updatedA reusable product base changed locally.product
product.archivedA reusable product base was archived.product
variant.createdA sellable product variant was created.variant
variant.updatedA sellable product variant changed locally.variant
variant.archivedA sellable product variant was archived.variant
inventory.movement_createdAn immutable inventory movement was recorded.movement, balance, publicationJobId
inventory.publishedCurrent inventory quantity was published to a listing binding.listingId, bindingId, variantId, quantity
warehouse.createdA warehouse was created, by you or together with its seller (the default one).warehouse
warehouse.updatedA warehouse changed: its name, its external ID, whether it is the seller's default, or it was reactivated.warehouse
warehouse.archivedA warehouse was archived.warehouse
marketplace_product.createdA marketplace product (content shared by offers) was created locally or imported.marketplaceProduct
marketplace_product.updatedA marketplace product changed: its local content, its variations, or what the marketplace shows.marketplaceProduct, behavior, jobId
listing.createdA local marketplace listing draft was created.listing
listing.updatedA listing or its synchronization changed.listing, behavior, jobId, bindingId
listing.deletedA local listing record was removed by an explicit choice.listing, publication
listing.status_changedThe marketplace status of a listing or of one of its lines changed.listingId, connectionId, marketplaceProductId, source, listing, bindings
listing.external_changedA marketplace listing change was observed.listing, behavior, recordUpdated, productId, variantIds, observed
listing.conflict_createdA 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_resolvedA listing conflict was resolved. Only the local desired data changed: nothing was published.listingId, conflictId, group, paths, resolution
asset.readyAn asset passed its checks and can be used (`ready`).assetId, class, status
asset.failedAn asset was refused or its upload was never completed (`failed`). Its file was discarded.assetId, class, status, failureReason
order.createdAn order appeared in marketapi: by import, reconciliation or a marketplace notification.order
order.updatedAn order changed: its status, totals, lines, payments or the link of a line.order
order.cancelledAn order was cancelled. This event is sent alone: the cancellation does not produce `order.updated`.order
job.completedA job finished with every item done.job
job.partially_completedA job finished with some items failed.job
job.failedA 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:

HeaderValue
X-Marketapi-Eventthe type
X-Marketapi-Delivery-Idid of this delivery (changes on every redelivery)
X-Marketapi-Signaturet=<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.