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:
storeIdes omitido de cada item (el caller ya lo conoce por su Bearer token).priceCentses el precio en centavos de la moneda indicada encurrency.currencyes el código ISO 4217 de la tienda (por defecto"PEN"para Perú).descriptionpuede sernull.nextCursoresnullcuando no hay más páginas.- La lista no incluye
media,categories,collectionsnitags— usarGET /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:
axesson los ejes de variación de la tienda (e.g. "Talla", "Color").storeIdes omitido.variantsson las combinaciones reales del producto. Cada variante puede tener hasta 2 ejes (axisValueId1,axisValueId2).productIdes omitido.priceCentsen la variante puede sernull(hereda del producto) o un precio propio.stockpuede sernull(ilimitado) o un entero (stock controlado).media.pathes 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
- En
/t/[slug]/conectar, copiá el Webhook URL y el Webhook Secret. - En tu panel MP → Aplicaciones → Webhooks, configurá:
- URL:
https://floofguard.store/api/webhooks/mp/<store_id> - Eventos:
payment(opayment.createdypayment.updated) - Secret de la firma: pegá el valor copiado en el paso 1.
- URL:
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
- Validar HMAC (
x-signature+x-request-id). - Filtrar: solo
payment.created/payment.updated. - Resolver access token del comerciante (configurado en
/t/[slug]/conectar). - Hacer
GET /v1/payments/{id}a la API de MP con el access token del comerciante. - Filtrar: solo
status = "approved". - Resolver
external_reference→ orden interna. - Marcar orden
paid(idempotente: si ya estaba pagada con el mismopaymentId, devuelvealreadyPaid: truesin writes). - 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
paidde 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,sendEmailymarkPaidEmailSentno son atómicos: si el envío del email falla, la orden quedapaidpero sin email enviado. El próximo retry de MP no reenviará el email (porquealreadyPaid: trueen el segundo intento).- No se valida clock skew del
tsdel header (deuda documentada — suficiente para MVP A). - El access token y el webhook secret nunca aparecen en logs.