Autenticación

Todas las rutas de /api/v1/* requieren un Bearer token en el header Authorization.

El token se genera al crear tu tienda en el dashboard, en la página /t/[slug]/conectar. Cada tienda tiene su propio token; podés regenerarlo en cualquier momento (invalida el anterior inmediatamente — transición atómica).

Header esperado

Authorization: Bearer fg_live_xxxxxxxxxxxxxxxxxxxxxxxx

El formato del token es fg_live_ seguido de 24 caracteres (longitud total: 32 chars).

Ejemplo cURL

curl -H "Authorization: Bearer fg_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
  https://floofguard.store/api/v1/products

Errores

| HTTP | Body | Significado | |---|---|---| | 401 | {"error":"MISSING_BEARER"} | Header Authorization ausente, sin prefijo Bearer , o token vacío | | 401 | {"error":"INVALID_BEARER"} | Token no coincide con ninguna key activa en la tienda | | 429 | {"error":"RATE_LIMITED"} | Más de 60 req/min en esa tienda | | 500 | {"error":"INTERNAL"} | Error inesperado del servidor |

El header WWW-Authenticate: Bearer realm="Floofguard API" se incluye en las respuestas 401.

Rate limit

60 requests por minuto por tienda (sliding window). Headers presentes en cada respuesta exitosa:

| Header | Descripción | |---|---| | X-RateLimit-Limit | Límite configurado (60) | | X-RateLimit-Remaining | Requests disponibles en la ventana actual | | X-RateLimit-Reset | Unix timestamp (segundos) en que se libera la ventana | | Retry-After | Segundos hasta poder reintentar (solo en respuestas 429) |

CORS

Todos los endpoints de /api/v1/* responden con Access-Control-Allow-Origin: *, por lo que podés llamarlos desde el browser de cualquier origen. Las preflight OPTIONS no requieren autenticación.

Productos

GET /api/v1/products

Lista productos de la tienda con paginación por cursor.

Query params

| param | tipo | default | descripción | |---|---|---|---| | limit | int | 20 | Máximo 100. Valores fuera de rango son clampeados. | | cursor | string | — | Cursor opaco devuelto en nextCursor de la respuesta anterior. | | categorySlug | string | — | Filtra por categoría (slug). Si el slug no existe en la tienda, devuelve lista vacía. | | collectionSlug | string | — | Filtra por colección (slug). Si no existe, lista vacía. | | tagSlug | string | — | Filtra por tag (slug). Si no existe, lista vacía. | | q | string | — | Búsqueda de texto en name (case-insensitive, substring). | | active | "true" / "false" | — | Filtra por estado activo/inactivo. Sin este param, devuelve todos. |

Response 200

{
  "data": [
    {
      "id": "abc123",
      "slug": "polo-oversize-algodon",
      "name": "Polo oversize de algodón",
      "description": "Descripción del producto o null",
      "priceCents": 12900,
      "currency": "PEN",
      "active": true,
      "createdAt": "2026-05-20T10:00:00.000Z",
      "updatedAt": "2026-05-20T10:00:00.000Z"
    }
  ],
  "nextCursor": "eyJpZCI6ImFiYzEyMyIsImNyZWF0ZWRBdCI6Ii4uLiJ9"
}

Notas sobre el shape:

  • storeId es omitido de cada item (el caller ya lo conoce por su Bearer token).
  • priceCents es el precio en centavos de la moneda indicada en currency.
  • currency es el código ISO 4217 de la tienda (por defecto "PEN" para Perú).
  • description puede ser null.
  • nextCursor es null cuando no hay más páginas.
  • La lista no incluye media, categories, collections ni tags — usar GET /api/v1/products/[slug] para el detalle con relaciones.

Orden: createdAt DESC, id DESC (estable para cursor).

Ejemplo JS (fetch)

const res = await fetch("https://floofguard.store/api/v1/products?limit=20", {
  headers: { Authorization: `Bearer ${process.env.FG_TOKEN}` },
});
const { data, nextCursor } = await res.json();

Paginación

let cursor;
do {
  const url = new URL("https://floofguard.store/api/v1/products");
  url.searchParams.set("limit", "20");
  if (cursor) url.searchParams.set("cursor", cursor);

  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.FG_TOKEN}` },
  });
  const body = await res.json();
  // procesar body.data ...
  cursor = body.nextCursor;
} while (cursor);

Ejemplo PHP (cURL)

$ch = curl_init("https://floofguard.store/api/v1/products?limit=20");
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer " . getenv("FG_TOKEN")]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$body = curl_exec($ch);
$result = json_decode($body, true);
$products = $result["data"];
$nextCursor = $result["nextCursor"];

Ejemplo Python (requests)

import os, requests

r = requests.get(
    "https://floofguard.store/api/v1/products",
    headers={"Authorization": f"Bearer {os.environ['FG_TOKEN']}"},
    params={"limit": 20},
)
body = r.json()
products = body["data"]
next_cursor = body["nextCursor"]

GET /api/v1/products/[slug]

Devuelve un producto por slug con sus relaciones populadas: media, categories, collections, tags.

Response 200

{
  "data": {
    "id": "abc123",
    "slug": "polo-oversize-algodon",
    "name": "Polo oversize de algodón",
    "description": null,
    "priceCents": 12900,
    "currency": "PEN",
    "active": true,
    "createdAt": "2026-05-20T10:00:00.000Z",
    "updatedAt": "2026-05-20T10:00:00.000Z",
    "media": [
      {
        "id": "med_xyz",
        "type": "image",
        "mimeType": "image/jpeg",
        "path": "/media/abc123/foto.jpg",
        "sizeBytes": 204800,
        "width": 800,
        "height": 600,
        "sortOrder": 0,
        "createdAt": "2026-05-20T10:00:00.000Z"
      }
    ],
    "categories": [{ "id": "cat_1", "slug": "polos", "name": "Polos", "sortOrder": 0, "createdAt": "..." }],
    "collections": [],
    "tags": [],
    "axes": [
      { "id": "axis_1", "name": "Talla", "sortOrder": 0 }
    ],
    "variants": [
      {
        "id": "var_1",
        "axisValueId1": "val_m",
        "axisValueId2": null,
        "priceCents": 12900,
        "stock": 50,
        "sku": "POLO-M",
        "active": true
      }
    ]
  }
}

Notas sobre el shape del detalle:

  • axes son los ejes de variación de la tienda (e.g. "Talla", "Color"). storeId es omitido.
  • variants son las combinaciones reales del producto. Cada variante puede tener hasta 2 ejes (axisValueId1, axisValueId2). productId es omitido.
  • priceCents en la variante puede ser null (hereda del producto) o un precio propio.
  • stock puede ser null (ilimitado) o un entero (stock controlado).
  • media.path es la ruta del archivo en el storage, no una URL completa.

#### Response 404

json { "error": "NOT_FOUND" }

Órdenes

POST /api/v1/orders

Crea una orden en estado pending. Idempotente por externalReference: si ya existe una orden con ese valor en la tienda, devuelve 200 con la orden existente (sin crear duplicados).

Nota: el endpoint NO genera un checkout de MercadoPago. El flujo esperado es que la landing del comerciante gestione su propio MP con su access token; este endpoint solo registra la orden en Floofguard para tracking y disparo del email post-pago cuando MP notifique el webhook.

Body

{
  "externalReference": "cart_550e8400-e29b-41d4-a716-446655440000",
  "customer": {
    "email": "comprador@ejemplo.dev",
    "name": "Juan Pérez",
    "phone": "+51999111222"
  },
  "shippingMethodId": "shp_abc123",
  "items": [
    {
      "productId": "prod_xyz",
      "variantId": "var_abc",
      "quantity": 1
    }
  ]
}

| campo | tipo | requerido | descripción | |---|---|---|---| | externalReference | string (1-128 chars) | si | ID único de la orden en tu sistema (cart ID, UUID, etc.). Clave de idempotencia. | | customer.email | string (email válido) | si | Email del comprador. | | customer.name | string (max 200) | no | Nombre del comprador. | | customer.phone | string (max 50) | no | Teléfono del comprador. | | shippingMethodId | string | si | ID del método de envío activo de la tienda. | | items | array (1-100 items) | si | Ítems del pedido. | | items[].productId | string | si | ID del producto. | | items[].variantId | string | null | no | ID de la variante. null o ausente para productos sin variantes. | | items[].quantity | int (1-10000) | si | Cantidad solicitada. |

Precios: el cliente NO envía precios. El servidor los calcula server-side desde el producto/variante. Si la variante tiene priceCents propio, ese se usa; de lo contrario hereda el precio del producto.

Response 200

{
  "data": {
    "id": "ord_abc123",
    "externalReference": "cart_550e8400-e29b-41d4-a716-446655440000",
    "subtotalCents": 12900,
    "shippingCostCents": 1500,
    "totalCents": 14400,
    "currency": "PEN",
    "status": "pending"
  }
}

status posibles: "pending" | "paid" | "fulfilled" | "cancelled".

En idempotency (misma externalReference): la respuesta es idéntica pero corresponde a la orden pre-existente.

Errores comunes

| HTTP | error | details | Descripción | |---|---|---|---| | 400 | VALIDATION_ERROR | "Invalid JSON body" / mensaje Zod | Body no es JSON válido o falla validación de schema | | 400 | EMPTY_ORDER | — | El array items está vacío | | 400 | PRODUCT_NOT_FOUND | { itemIndex, productId } | El producto no existe en la tienda | | 400 | VARIANT_NOT_FOUND | { itemIndex } | La variante no existe (ID inválido) | | 400 | VARIANT_NOT_FOR_PRODUCT | { itemIndex } | La variante existe pero pertenece a otro producto | | 400 | OUT_OF_STOCK | { itemIndex, variantId, available, requested } | Stock insuficiente | | 400 | SHIPPING_METHOD_NOT_FOUND | — | El método de envío no existe en la tienda | | 400 | SHIPPING_METHOD_INACTIVE | — | El método de envío existe pero está inactivo |

Nota anti-enumeración: VARIANT_NOT_FOUND y VARIANT_NOT_FOR_PRODUCT tienen el mismo shape de details (solo itemIndex) para no revelar si el variantId existe en el sistema.

Ejemplo Python

import os, requests

r = requests.post(
    "https://floofguard.store/api/v1/orders",
    headers={"Authorization": f"Bearer {os.environ['FG_TOKEN']}"},
    json={
        "externalReference": "cart_abc123",
        "customer": {"email": "juan@ejemplo.dev", "name": "Juan Pérez"},
        "shippingMethodId": "shp_xyz",
        "items": [
            {"productId": "prod_abc", "variantId": "var_123", "quantity": 1}
        ],
    },
)
data = r.json()["data"]
print(data["id"], data["totalCents"], data["status"])

Ejemplo JS (fetch)

const res = await fetch("https://floofguard.store/api/v1/orders", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.FG_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    externalReference: "cart_abc123",
    customer: { email: "juan@ejemplo.dev" },
    shippingMethodId: "shp_xyz",
    items: [{ productId: "prod_abc", quantity: 1 }],
  }),
});
const { data } = await res.json();

Ejemplo PHP (cURL)

$payload = json_encode([
    "externalReference" => "cart_abc123",
    "customer" => ["email" => "juan@ejemplo.dev"],
    "shippingMethodId" => "shp_xyz",
    "items" => [["productId" => "prod_abc", "quantity" => 1]],
]);

$ch = curl_init("https://floofguard.store/api/v1/orders");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("FG_TOKEN"),
    "Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = json_decode(curl_exec($ch), true);
$order = $result["data"];

Webhook entrante de MercadoPago

Floofguard expone POST /api/webhooks/mp/[storeId] para recibir notificaciones de pago de MercadoPago. Cuando MP confirma un pago, Floofguard marca la orden como paid y envía el email post-pago al comprador.

Setup

  1. En /t/[slug]/conectar, copiá el Webhook URL y el Webhook Secret.
  2. En tu panel MP → Aplicaciones → Webhooks, configurá:
    • URL: https://floofguard.store/api/webhooks/mp/<store_id>
    • Eventos: payment (o payment.created y payment.updated)
    • Secret de la firma: pegá el valor copiado en el paso 1.

El <store_id> es el ID interno de tu tienda (visible en la URL del dashboard o en /t/[slug]/conectar).

Verificación HMAC

MercadoPago firma cada notificación con HMAC-SHA256. Floofguard valida dos headers:

| Header | Descripción | |---|---| | x-signature | Firma MP, formato ts=<unix_ms>,v1=<hex_sha256> | | x-request-id | ID único de la request (opaque string, requerido para el manifest) |

El manifest firmado es:

id:<body.data.id>;request-id:<x-request-id>;ts:<ts>;

Si la firma no coincide (o falta cualquiera de los headers), se devuelve 401 con timing constante (anti-enumeración: no se distingue entre "store inválido", "sin secret" o "firma incorrecta").

Eventos procesados

Solo se procesan action = "payment.created" o action = "payment.updated". Otros eventos reciben 200 con { "ok": true, "ignored": "action" }.

Flujo cuando llega un pago approved

  1. Validar HMAC (x-signature + x-request-id).
  2. Filtrar: solo payment.created / payment.updated.
  3. Resolver access token del comerciante (configurado en /t/[slug]/conectar).
  4. Hacer GET /v1/payments/{id} a la API de MP con el access token del comerciante.
  5. Filtrar: solo status = "approved".
  6. Resolver external_reference → orden interna.
  7. Marcar orden paid (idempotente: si ya estaba pagada con el mismo paymentId, devuelve alreadyPaid: true sin writes).
  8. Si es la primera vez que se marca paid: enviar email post-pago al comprador vía Resend (best-effort — si falla, la orden queda paid de todas formas).

Ejemplo de payload MP

{
  "action": "payment.updated",
  "data": { "id": "12345678" }
}

Floofguard obtiene el detalle del pago internamente vía GET /v1/payments/12345678 usando el access token del comerciante. El body del webhook solo necesita action y data.id.

Respuestas

| Caso | HTTP | Body | |---|---|---| | Pago procesado (primera vez) | 200 | { "ok": true, "orderId": "ord_abc", "alreadyPaid": false } | | Pago ya registrado (retry idempotente) | 200 | { "ok": true, "orderId": "ord_abc", "alreadyPaid": true } | | Evento ignorado (action distinto a payment) | 200 | { "ok": true, "ignored": "action" } | | Pago no aprobado | 200 | { "ok": true, "ignored": "not_approved" } | | Orden no encontrada | 200 | { "ok": true, "ignored": "order_not_found" } | | Sin access token configurado | 200 | { "ok": true, "ignored": "no_access_token" } | | Firma inválida / store no existe / sin secret | 401 | { "ok": false, "error": "invalid_signature" } | | MP rate limit o no disponible | 503 | { "ok": false, "error": "mp_unreachable" } |

Las respuestas 503 hacen que MP reintente automáticamente. Las respuestas 200 (incluyendo los ignored) no provocan retries.

Caveats

  • markPaid, sendEmail y markPaidEmailSent no son atómicos: si el envío del email falla, la orden queda paid pero sin email enviado. El próximo retry de MP no reenviará el email (porque alreadyPaid: true en el segundo intento).
  • No se valida clock skew del ts del header (deuda documentada — suficiente para MVP A).
  • El access token y el webhook secret nunca aparecen en logs.