Guía

Inventario sin dos escritores

Moku separa stock físico y reservas para calcular disponibilidad y proteger unidades comprometidas.

on_handStock físico

La cantidad que modifica el sistema con autoridad.

reservedReservado

Unidades retenidas por checkout, pedido o reserva pública.

availableDisponible

on_hand - reserved. Publica este valor en canales externos.

Una ubicación predeterminada por vendedor

Cada item devuelve location_id: default, una ubicación contable explícita dentro de vendor_id. El mismo nombre en dos vendedores no representa stock compartido. Los IDs actuales de inventario no cambian: guárdalos como valores opacos, sin recalcularlos.

Todavía no existen administración de bodegas ni asignación entre ubicaciones. Un futuro inventario administrado por un marketplace usará otra ubicación para no descontar dos veces el stock del vendedor.

Reservas acotadas

Cada item admite hasta 256 reservas activas. Al alcanzar el límite, una reserva nueva falla con RESERVATION_CONFLICT; renovar o liquidar una reserva vigente sigue permitido. Las reservas vencidas liberan disponibilidad y capacidad inmediatamente, aunque la limpieza llegue después.

El catálogo no reemplaza el inventario autorizado: si falta un registro de stock o está mal formado, la operación falla de forma cerrada. No se toma stock de una instantánea del producto.

Cada item también declara stock_pool_id: default. El ID opaco existente no cambia; futuros pools serán aditivos.

Seguir la guía de producto a stock

Tu primera escritura: ajustar stock

Un ajuste fija el stock físico (on_hand) de un item. Antes de enviarlo, confirma lo siguiente:

  1. Un token de lectura y escritura, creado en Configuración → API y guardado en MOKU_PAT. Un token de solo lectura responde INSUFFICIENT_SCOPE.
  2. Que Moku administra el stock: en GET /vendors/{vendor_id}/authority, el Stock Pool default tiene master.type: moku. Si un canal administra el inventario, ese canal es quien escribe el stock. Fuente de verdad y conexiones
  3. Que exportaste MOKU_ALLOW_WRITES=1 solo en la terminal donde harás el cambio. Los ejemplos se niegan a escribir sin esa variable.

La API usa producción, aunque los pagos estén en sandbox. Los IDs y las respuestas de esta guía son ficticios; reemplázalos por los de tu tienda.

1. Encuentra el item de inventario

Recorre inventory-items con limit y page_after. Compara product_id y variation_id en tu integración y guarda el id del item que coincide. Esta ruta no acepta un filtro product_id.

Inventario de la tienda

Solicitud · ejemplo ilustrativo

cURL
: "${MOKU_PAT:?}" &&
curl --silent --show-error --fail-with-body \
  'https://moku.cl/api/v1/vendors/vendor_demo_ceramica/inventory-items' \
  --header "Authorization: Bearer ${MOKU_PAT}" \
  --header 'Accept: application/json' \
  --get \
  --data-urlencode 'limit=50'
Node.js

Módulo ES para Node.js 18 o posterior (guárdalo, por ejemplo, como ejemplo.mjs). Usa el fetch incluido, sin dependencias. No debe ejecutarse en el navegador.

const token = process.env.MOKU_PAT;
if (!token) throw new Error('MOKU_PAT');

const url = new URL('https://moku.cl/api/v1/vendors/vendor_demo_ceramica/inventory-items');
url.searchParams.set('limit', '50');
const response = await fetch(url, {
  method: 'GET',
  headers: {
    Accept: 'application/json',
    Authorization: `Bearer ${token}`,
  },
  signal: AbortSignal.timeout(30_000),
});
const result = await response.json();
if (!response.ok) console.error(`HTTP ${response.status}`);
(response.ok ? console.log : console.error)(JSON.stringify(result, null, 2));
process.exitCode = response.ok ? 0 : 1;
PHP

Ejemplo para PHP CLI con la extensión cURL. No es un plugin de WordPress ni debe ejecutarse en el navegador.

<?php
$token = getenv('MOKU_PAT');
if ($token === false || $token === '') {
    throw new RuntimeException('MOKU_PAT');
}

$url = 'https://moku.cl/api/v1/vendors/vendor_demo_ceramica/inventory-items';
$query = [
    'limit' => '50',
];
$url .= '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
$curl = curl_init($url);
if ($curl === false) {
    throw new RuntimeException('curl_init');
}
curl_setopt_array($curl, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => 'GET',
    CURLOPT_CONNECTTIMEOUT => 10,
    CURLOPT_TIMEOUT => 30,
    CURLOPT_HTTPHEADER => [
        'Accept: application/json',
        'Authorization: Bearer ' . $token,
    ],
]);
$response = curl_exec($curl);
if ($response === false) {
    $message = curl_error($curl);
    curl_close($curl);
    throw new RuntimeException($message);
}
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
$result = json_decode($response, false, 512, JSON_THROW_ON_ERROR);
$failed = $status < 200 || $status >= 300;
if ($failed) {
    fwrite(STDERR, "HTTP {$status}\n");
}
fwrite(
    $failed ? STDERR : STDOUT,
    json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . PHP_EOL
);
exit($failed ? 1 : 0);

Respuesta 200 application/json

{
  "data": [
    {
      "id": "WyJwcm9kdWN0X2RlbW9fdGF6b24iLCJ2YXJpYXRpb25fZGVtb19henVsIl0",
      "vendor_id": "vendor_demo_ceramica",
      "location_id": "default",
      "stock_pool_id": "default",
      "product_id": "product_demo_tazon",
      "product_name": "Tazón de cerámica",
      "product_image_url": "https://storage.googleapis.com/moku-348cf-marketplace-media/seller-media/v1/1a2b61ce63dd668ed37dab741499416563ea6f3f9868274f25e4d99a4a5dfe62/83f70f6a565e1bf5a0f14317e850dc382e57ca3ce623d4c42ef2a100d8670144.webp",
      "variation_id": "variation_demo_azul",
      "variation_options": {
        "Color": "Azul"
      },
      "sku": "TAZ-AZUL",
      "on_hand": 10,
      "reserved": 2,
      "available": 8,
      "version": 7,
      "status": "low_stock"
    },
    {
      "id": "WyJwcm9kdWN0X2RlbW9fdmFzbyIsbnVsbF0",
      "vendor_id": "vendor_demo_ceramica",
      "location_id": "default",
      "stock_pool_id": "default",
      "product_id": "product_demo_vaso",
      "product_name": "Vaso de greda",
      "product_image_url": "https://storage.googleapis.com/moku-348cf-marketplace-media/seller-media/v1/1a2b61ce63dd668ed37dab741499416563ea6f3f9868274f25e4d99a4a5dfe62/9737f82749c2e76e531b41e44a75031a1a61c76aa8126e115b52ea6eeef633e0.webp",
      "variation_id": null,
      "variation_options": {},
      "sku": "VAS-GREDA",
      "on_hand": 4,
      "reserved": 0,
      "available": 4,
      "version": 1,
      "status": "low_stock"
    }
  ],
  "page": {
    "next_cursor": null
  }
}

Si no hay coincidencia, completa todas las páginas antes de concluir que falta. Detén el flujo y revisa los IDs; no sustituyas una lectura fallida por stock cero ni inventes un item.

2. Lee el item y su versión

Guarda version: el ajuste solo se aplica si el item no cambió desde tu lectura.

Stock y versión del item

Solicitud · ejemplo ilustrativo

cURL
: "${MOKU_PAT:?}" &&
curl --silent --show-error --fail-with-body \
  'https://moku.cl/api/v1/vendors/vendor_demo_ceramica/inventory-items/WyJwcm9kdWN0X2RlbW9fdGF6b24iLCJ2YXJpYXRpb25fZGVtb19henVsIl0' \
  --header "Authorization: Bearer ${MOKU_PAT}" \
  --header 'Accept: application/json'
Node.js

Módulo ES para Node.js 18 o posterior (guárdalo, por ejemplo, como ejemplo.mjs). Usa el fetch incluido, sin dependencias. No debe ejecutarse en el navegador.

const token = process.env.MOKU_PAT;
if (!token) throw new Error('MOKU_PAT');

const url = new URL('https://moku.cl/api/v1/vendors/vendor_demo_ceramica/inventory-items/WyJwcm9kdWN0X2RlbW9fdGF6b24iLCJ2YXJpYXRpb25fZGVtb19henVsIl0');
const response = await fetch(url, {
  method: 'GET',
  headers: {
    Accept: 'application/json',
    Authorization: `Bearer ${token}`,
  },
  signal: AbortSignal.timeout(30_000),
});
const result = await response.json();
if (!response.ok) console.error(`HTTP ${response.status}`);
(response.ok ? console.log : console.error)(JSON.stringify(result, null, 2));
process.exitCode = response.ok ? 0 : 1;
PHP

Ejemplo para PHP CLI con la extensión cURL. No es un plugin de WordPress ni debe ejecutarse en el navegador.

<?php
$token = getenv('MOKU_PAT');
if ($token === false || $token === '') {
    throw new RuntimeException('MOKU_PAT');
}

$url = 'https://moku.cl/api/v1/vendors/vendor_demo_ceramica/inventory-items/WyJwcm9kdWN0X2RlbW9fdGF6b24iLCJ2YXJpYXRpb25fZGVtb19henVsIl0';
$curl = curl_init($url);
if ($curl === false) {
    throw new RuntimeException('curl_init');
}
curl_setopt_array($curl, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => 'GET',
    CURLOPT_CONNECTTIMEOUT => 10,
    CURLOPT_TIMEOUT => 30,
    CURLOPT_HTTPHEADER => [
        'Accept: application/json',
        'Authorization: Bearer ' . $token,
    ],
]);
$response = curl_exec($curl);
if ($response === false) {
    $message = curl_error($curl);
    curl_close($curl);
    throw new RuntimeException($message);
}
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
$result = json_decode($response, false, 512, JSON_THROW_ON_ERROR);
$failed = $status < 200 || $status >= 300;
if ($failed) {
    fwrite(STDERR, "HTTP {$status}\n");
}
fwrite(
    $failed ? STDERR : STDOUT,
    json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . PHP_EOL
);
exit($failed ? 1 : 0);

Respuesta 200 application/json

{
  "data": {
    "id": "WyJwcm9kdWN0X2RlbW9fdGF6b24iLCJ2YXJpYXRpb25fZGVtb19henVsIl0",
    "vendor_id": "vendor_demo_ceramica",
    "location_id": "default",
    "stock_pool_id": "default",
    "product_id": "product_demo_tazon",
    "product_name": "Tazón de cerámica",
    "product_image_url": "https://storage.googleapis.com/moku-348cf-marketplace-media/seller-media/v1/1a2b61ce63dd668ed37dab741499416563ea6f3f9868274f25e4d99a4a5dfe62/83f70f6a565e1bf5a0f14317e850dc382e57ca3ce623d4c42ef2a100d8670144.webp",
    "variation_id": "variation_demo_azul",
    "variation_options": {
      "Color": "Azul"
    },
    "sku": "TAZ-AZUL",
    "on_hand": 10,
    "reserved": 2,
    "available": 8,
    "version": 7,
    "status": "low_stock"
  }
}

3. Ajusta con expected_version e Idempotency-Key

Envía la cantidad final, no una diferencia, junto con la versión leída. Usa una Idempotency-Key nueva para este cambio y reutilízala solo para repetir exactamente la misma solicitud si pierdes la respuesta.

Ajustar stock físico de forma idempotente

Solicitud · ejemplo ilustrativo

cURL
: "${MOKU_PAT:?}" &&
test "${MOKU_ALLOW_WRITES:-}" = "1" &&
curl --silent --show-error --fail-with-body \
  --request POST \
  'https://moku.cl/api/v1/vendors/vendor_demo_ceramica/inventory-items/WyJwcm9kdWN0X2RlbW9fdGF6b24iLCJ2YXJpYXRpb25fZGVtb19henVsIl0/adjustments' \
  --header "Authorization: Bearer ${MOKU_PAT}" \
  --header 'Accept: application/json' \
  --header 'Idempotency-Key: demo_stock_001' \
  --header 'Content-Type: application/json' \
  --data-raw '{
  "target_on_hand": 8,
  "expected_version": 7
}'
Node.js

Módulo ES para Node.js 18 o posterior (guárdalo, por ejemplo, como ejemplo.mjs). Usa el fetch incluido, sin dependencias. No debe ejecutarse en el navegador.

const token = process.env.MOKU_PAT;
if (!token) throw new Error('MOKU_PAT');
if (process.env.MOKU_ALLOW_WRITES !== '1') throw new Error('MOKU_ALLOW_WRITES=1');

const url = new URL('https://moku.cl/api/v1/vendors/vendor_demo_ceramica/inventory-items/WyJwcm9kdWN0X2RlbW9fdGF6b24iLCJ2YXJpYXRpb25fZGVtb19henVsIl0/adjustments');
const body = {
  "target_on_hand": 8,
  "expected_version": 7
};
const response = await fetch(url, {
  method: 'POST',
  headers: {
    Accept: 'application/json',
    Authorization: `Bearer ${token}`,
    'Idempotency-Key': 'demo_stock_001',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(body),
  signal: AbortSignal.timeout(30_000),
});
const result = await response.json();
if (!response.ok) console.error(`HTTP ${response.status}`);
(response.ok ? console.log : console.error)(JSON.stringify(result, null, 2));
process.exitCode = response.ok ? 0 : 1;
PHP

Ejemplo para PHP CLI con la extensión cURL. No es un plugin de WordPress ni debe ejecutarse en el navegador.

<?php
$token = getenv('MOKU_PAT');
if ($token === false || $token === '') {
    throw new RuntimeException('MOKU_PAT');
}
if (getenv('MOKU_ALLOW_WRITES') !== '1') {
    throw new RuntimeException('MOKU_ALLOW_WRITES=1');
}

$url = 'https://moku.cl/api/v1/vendors/vendor_demo_ceramica/inventory-items/WyJwcm9kdWN0X2RlbW9fdGF6b24iLCJ2YXJpYXRpb25fZGVtb19henVsIl0/adjustments';
$body = <<<'MOKU_REQUEST_JSON'
{
  "target_on_hand": 8,
  "expected_version": 7
}
MOKU_REQUEST_JSON;
$curl = curl_init($url);
if ($curl === false) {
    throw new RuntimeException('curl_init');
}
curl_setopt_array($curl, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_CONNECTTIMEOUT => 10,
    CURLOPT_TIMEOUT => 30,
    CURLOPT_HTTPHEADER => [
        'Accept: application/json',
        'Authorization: Bearer ' . $token,
        'Idempotency-Key: demo_stock_001',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => $body,
]);
$response = curl_exec($curl);
if ($response === false) {
    $message = curl_error($curl);
    curl_close($curl);
    throw new RuntimeException($message);
}
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
$result = json_decode($response, false, 512, JSON_THROW_ON_ERROR);
$failed = $status < 200 || $status >= 300;
if ($failed) {
    fwrite(STDERR, "HTTP {$status}\n");
}
fwrite(
    $failed ? STDERR : STDOUT,
    json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . PHP_EOL
);
exit($failed ? 1 : 0);

Respuesta 200 application/json

{
  "data": {
    "id": "WyJwcm9kdWN0X2RlbW9fdGF6b24iLCJ2YXJpYXRpb25fZGVtb19henVsIl0",
    "vendor_id": "vendor_demo_ceramica",
    "location_id": "default",
    "stock_pool_id": "default",
    "product_id": "product_demo_tazon",
    "product_name": "Tazón de cerámica",
    "product_image_url": "https://storage.googleapis.com/moku-348cf-marketplace-media/seller-media/v1/1a2b61ce63dd668ed37dab741499416563ea6f3f9868274f25e4d99a4a5dfe62/83f70f6a565e1bf5a0f14317e850dc382e57ca3ce623d4c42ef2a100d8670144.webp",
    "variation_id": "variation_demo_azul",
    "variation_options": {
      "Color": "Azul"
    },
    "sku": "TAZ-AZUL",
    "on_hand": 8,
    "reserved": 2,
    "available": 6,
    "version": 8,
    "status": "low_stock"
  }
}

Si la respuesta es INVENTORY_VERSION_CONFLICT, alguien cambió el item: vuelve a leerlo, decide la nueva cantidad y reintenta con una clave nueva. Ver el error en la referencia

Omite authority_connection_id cuando Moku controla el Stock Pool. Cuando WooCommerce tiene rol inventory: source, envía el ID exacto de esa conexión.

Los replays exactos se reconocen durante al menos 30 días y la identidad incluye token y operación. Nunca reutilices intencionalmente una clave para otro trabajo; una credencial nueva no comparte los recibos de la anterior.

Reservas públicas

Una conexión con rol de destino y stock Moku puede reservar entre 60 y 3600 segundos, renovar, consumir o liberar. Una reserva creada por un pedido externo se administra exclusivamente mediante el estado del pedido.

Conflictos

  1. Ante INVENTORY_VERSION_CONFLICT, vuelve a leer el item.
  2. Recalcula target_on_hand sobre la versión actual.
  3. Reintenta con una clave nueva.

ON_HAND_BELOW_RESERVED impide establecer stock por debajo de compromisos activos. AUTHORITY_CONFLICT indica que otro sistema es el escritor vigente.