EPICODES · MAYORISTA

API de Pedidos

Traé tus pedidos a tu propio sistema sin exportar el CSV a mano: número, juego, plataforma, licencia, importe en ARS y USDT, fechas y estado. Con sincronización incremental, así cada corrida te trae solo lo que cambió.

partner_orders_v1 · REST · JSON · solo lectura → ¿Buscabas la API de catálogo (precios y stock)?

1Endpoint y autenticación

DatoValor
URL basehttps://mayoristas.epicodes.com.ar/partner-api.php
El mismo dominio que la API de catálogo.
MétodoGET
API KeyTe la pasamos por privado. Va en el header X-Api-Key.
⚠️ La key va en el header, nunca en la URL. Un ?api_key=... queda escrito en los logs de acceso, en el historial del navegador y en la cabecera Referer de cualquier link que se abra desde ahí. Esta API se llama desde tu servidor, no desde el navegador de tus clientes.
Es de solo lectura. No hay ningún endpoint que cree, modifique o cancele pedidos. Una key filtrada expone tu historial comercial, nunca tu saldo ni tus compras.

Los tres endpoints

EndpointPara qué
GET /pingProbar la key: te dice de quién es, qué alcance tiene y qué cuentas incluye.
GET /ordersLa lista de pedidos, paginada.
GET /orders/{sale_number}Un pedido puntual, por su número.

2Cómo hacer la request

Empezá por /ping: confirma que la key anda y te muestra qué cuentas vas a recibir.

curl "https://mayoristas.epicodes.com.ar/partner-api.php/ping" \
  -H "X-Api-Key: TU_API_KEY"

La lista de pedidos

curl "https://mayoristas.epicodes.com.ar/partner-api.php/orders?limit=100" \
  -H "X-Api-Key: TU_API_KEY"

En Node

const r = await fetch(
  'https://mayoristas.epicodes.com.ar/partner-api.php/orders?order=updated&limit=200',
  { headers: { 'X-Api-Key': process.env.EPICODES_API_KEY } }
)
const data = await r.json()
if (!r.ok) throw new Error(data.error)     // el status HTTP es real: 401, 403, 429...
for (const p of data.orders) {
  // guardalo con UPSERT por sale_number (ver seccion 6)
}

En PHP

$ch = curl_init('https://mayoristas.epicodes.com.ar/partner-api.php/orders?limit=200');
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => ['X-Api-Key: ' . getenv('EPICODES_API_KEY')],
]);
$resp = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

3Estructura de la respuesta

{
  "ok": true,
  "api": "partner_orders_v1",
  "scope": "propios_y_subs",
  "count": 100,
  "has_more": true,
  "next_cursor": "eyJ0IjoiMjAyNi0wOS0wMlQwODowMDowMC4wMDBaIiwiaSI6NDgyMX0",
  "synced_through": "2026-09-09T14:22:31.000Z",
  "server_time": "2026-09-09T14:22:36.000Z",
  "orders": [ ... ]
}
CampoQué es
countCuántos pedidos trae esta página.
has_moretrue si quedan más. Seguí pidiendo con next_cursor hasta que sea false.
next_cursorDónde sigue el recorrido. Es opaco: guardalo tal cual, no lo interpretes. Puede cambiar de forma sin aviso.
synced_throughHasta qué instante está garantizado el corte. Todo lo anterior a esta marca ya te lo dimos.

4Campos de cada pedido

Identificación

CampoTipoDescripción
sale_numberintUsá este como clave primaria. Es el número de pedido, único y siempre presente.
order_idstring|nullIdentificador interno. Puede venir en null en pedidos históricos.
statusstringPENDING, FULFILLED o REFUNDED. Ver sección 5.
⚠️ No uses order_id como clave. Es nullable: los pedidos más viejos no lo tienen. Si lo usás como primary key, todo tu histórico colapsa en una sola fila con clave nula. La clave es sale_number.

Producto

CampoTipoDescripción
titlestringNombre del juego.
platformstring|nullPS4 o PS5.
licensestring|nullPrimario o Secundario.

Importes

CampoTipoDescripción
total_arsnumberLo que pagaste por ese pedido, en pesos.
total_usdtnumberLo mismo en USDT.
currencystring|nullCon cuál de las dos se cobró: ARS o USDT.

Los dos importes vienen siempre. currency te dice cuál es el que se cobró de verdad; el otro es la conversión.

Fechas (ISO 8601, siempre en UTC)

CampoTipoDescripción
created_atstringCuándo se hizo el pedido. No cambia nunca.
fulfilled_atstring|nullCuándo se entregó.
refunded_atstring|nullCuándo se devolvió, si se devolvió.
updated_atstringÚltimo cambio. Es el eje de la sincronización (sección 6).

Relaciones y extras

CampoTipoDescripción
order_sourcestring|nullinstant, cart, replacement, sin_cargo, admin.
replacement_for_order_idstring|nullSi es un reemplazo, a qué pedido reemplaza.
replaced_by_order_idstring|nullSi fue reemplazado, por cuál.
support_ticket_idstring|nullTicket de soporte asociado.
user_flagbooleanEl tilde de "cuenta usada" que marcás en el portal.
user_notestringLa nota que le pusiste al pedido en el portal.
resellerobject{ user_id, name } — cuál de tus cuentas hizo el pedido. Relevante si tu key incluye sub-revendedores.

5Estados y qué significan

Esta sección es la que evita que tu conciliación dé mal. Son tres cosas contraintuitivas:

1. PENDING ya está pagado. No significa "pendiente de pago": significa pendiente de entrega. El importe ya se descontó de tu saldo cuando hiciste el pedido. Si tu sistema lo trata como no cobrado, tu caja no va a cerrar.
2. Los reemplazos vienen en $0 y no son ventas nuevas. Si a un pedido tuyo le dimos una cuenta de reposición, vas a ver un pedido con order_source: "replacement" (y replacement_for_order_id apuntando al original) con importe cero. Es una entrega real, pero no es una venta: si la contás como tal, te queda una venta fantasma. Lo mismo con order_source: "sin_cargo", que son regalos o compensaciones.
3. Las devoluciones te llegan, marcadas. Un pedido devuelto no desaparece de la API: sigue viniendo con status: "REFUNDED" y su refunded_at. Es a propósito. Si lo filtráramos, tu fila quedaría en "entregado" para siempre y tu ganancia quedaría inflada sin que nada te avise. Cuando veas un pedido pasar a REFUNDED, descontalo.

Cómo clasificar cada renglón

Si…Es…Para tus números
status = "REFUNDED"DevoluciónNo es una venta. Restala.
order_source = "replacement"ReemplazoEntrega real en $0. No es venta nueva.
order_source = "sin_cargo"Regalo / compensación$0, sin original detrás.
ninguna de las anterioresVenta normalContala.

6Sincronización incremental

Esta es la sección importante. Hay dos modos y usar el equivocado hace que tu base mienta.

orderOrdena porCuándo usarlo
createdFecha del pedidoUna sola vez, al principio: la carga inicial de tu histórico.
updated (default)Último cambioSiempre después: es el que corre tu cron.
⚠️ No sincronices por created_at. La fecha de creación de un pedido nunca cambia. Si un pedido pasa de PENDING a FULFILLED, o se devuelve tres días después, su created_at sigue siendo el mismo — así que vas a verlo una única vez, en su primer estado, y no te vas a enterar de nada más. Con order=updated el pedido vuelve a aparecer cada vez que cambia.

El flujo completo

  1. Carga inicial. Pedí con order=created y andá siguiendo next_cursor hasta que has_more sea false. Descartá ese cursor cuando termines.
  2. Primer sync. Pedí con order=updated sin cursor. Guardá el next_cursor que te devuelve.
  3. De ahí en más. Cada corrida arranca con el cursor guardado, pagina hasta has_more: false, y guarda el último next_cursor.
// El cron, en pseudocodigo
let cursor = leerCursorGuardado()          // null la primera vez
do {
  const url = new URL('https://mayoristas.epicodes.com.ar/partner-api.php/orders')
  url.searchParams.set('order', 'updated')
  url.searchParams.set('limit', '200')
  if (cursor) url.searchParams.set('cursor', cursor)

  const r = await fetch(url, { headers: { 'X-Api-Key': KEY } })
  const d = await r.json()
  if (!r.ok) throw new Error(d.error)

  for (const p of d.orders) upsertPorSaleNumber(p)   // ← UPSERT, no INSERT
  cursor = d.next_cursor
  guardarCursor(cursor)
} while (d.has_more)
Guardá con UPSERT, no con INSERT. Un pedido te va a llegar varias veces a lo largo de su vida: cuando se crea, cuando se entrega, si se devuelve. Y a veces updated_at se mueve por cuestiones internas nuestras sin que cambie ningún campo que vos veas — por ejemplo cuando cerramos un período de facturación. Recibir un pedido "otra vez igual" es normal y no significa nada raro.
Podés rebobinar sin miedo. Si dudás de tu cursor, guardá uno más viejo y volvé a correr: la respuesta es idempotente y el UPSERT se encarga. Una práctica sana es solapar un minuto en cada corrida.

Atajos de arranque

ParámetroCon qué modoQué hace
updated_sinceupdatedArranca desde esa fecha en vez de desde el principio.
created_from / created_tocreatedAcota el rango del backfill.
statuslos dosFiltra por estado, separados por coma. Sin este parámetro vienen los tres, que es lo que querés.

Una fecha YYYY-MM-DD se interpreta como día argentino completo. Si mandás un ISO 8601 con zona, se respeta tal cual. Cuando mandás cursor, estos atajos se ignoran: el cursor ya dice desde dónde seguir.

7Paginación

Por cursor, no por página numerada: así no se te escapa ni se te duplica nada aunque entren pedidos nuevos mientras estás paginando.

Un cursor inválido devuelve 400, no una lista. Es a propósito: si te devolviéramos el histórico desde cero ante un cursor roto, un error tuyo se convertiría en una recarga completa silenciosa en cada corrida.

8El alcance de tu key

Cada key tiene un alcance fijo, que ves en /ping:

AlcanceQué incluye
propiosSolo los pedidos hechos con tu cuenta.
propios_y_subsLos tuyos más los de tus sub-revendedores. El campo reseller de cada pedido te dice de cuál.
⚠️ Si cambia tu alcance, rehacé el backfill. Cuando sumamos un revendedor nuevo a tu cuenta, o cuando ampliamos el alcance de tu key, sus pedidos viejos tienen fechas de modificación anteriores a tu cursor actual: no te van a llegar por el sync incremental. Corré una vez más el modo order=created y seguí.

9Límite de uso

60 requests por minuto por key. Para dimensionar: traer un histórico de ~1.400 pedidos a 200 por página son 7 requests. Un sync incremental que no encuentra novedades es una.

Sincronizar cada 5 o 10 minutos es más que suficiente y no toca el límite ni de cerca.

Si te pasás recibís 429 con el header Retry-After en segundos. Respetalo y reintentá.

10Manejo de errores

El código viene en el status HTTP. A diferencia de nuestra API de catálogo —que siempre responde 200 y pone el código adentro del JSON— acá el status es real. Tu librería HTTP lo va a interpretar bien sola.
StatuserrorQué pasó
401missing_api_keyFalta el header X-Api-Key.
403invalid_api_keyLa key no existe o está mal copiada.
403key_revokedLa key fue dada de baja. Pedinos una nueva.
400invalid_cursorEl cursor está corrupto. Volvé a empezar sin cursor.
400invalid_paramAlgún parámetro está mal. El campo message dice cuál.
404order_not_foundEse pedido no existe o no es tuyo.
429rate_limitedPasaste el límite. Mirá Retry-After.
{
  "ok": false,
  "error": "invalid_cursor",
  "message": "El cursor no es valido. Volve a empezar sin cursor."
}

11Qué NO devuelve esta API

Compatibilidad