09 · Admin GraphQL API
Mutations, webhooks, bulk ops para apps.
El Admin GraphQL API es la API de back-office de Shopify. Con ella, las apps crean productos, actualizan pedidos, gestionan clientes, e inyectan metafields. Tiene rate limiting basado en costo de query, operaciones bulk para datasets grandes, y webhooks para reaccionar a eventos de la tienda. El token del Admin API nunca sale del servidor.
-
npm install @shopify/admin-api-client— Instalar el cliente oficial de Admin API (una sola vez, desde brew-atlas-app/) -
mkdir -p ~/proyectos/shopify/brew-atlas/scripts/out— Crear el directorio de salida para exports -
npx tsx ~/proyectos/shopify/brew-atlas/scripts/sync-orders.ts— Exportar los ultimos 50 pedidos a orders.csv
Buscá los bloques con badge ✓ Ejecutar en el contenido. Los bloques con 📖 Referencia son ilustrativos — no los ejecutes.
Qué es y por qué existe
Cuando construyes una app para Shopify — no un theme, sino una app — el Admin GraphQL API es tu acceso principal al back-office del comerciante. Con el permiso del comerciante (OAuth), tu app puede leer pedidos, crear o actualizar productos, gestionar clientes, escribir metafields, y disparar prácticamente cualquier operación que un comerciante haría manualmente desde su panel.
El endpoint del Admin API tiene la forma:
https://{shop}.myshopify.com/admin/api/2026-04/graphql.jsonLa diferencia estructural respecto al Storefront API: el Admin API opera con el token de acceso del comerciante, no con un token público. Ese token se obtiene a través del flujo OAuth de instalación de app y se almacena en el servidor de tu app. Nunca lo envías al cliente.
La razón por la que Shopify tiene REST además de GraphQL en el Admin es histórica — REST llegó primero (2009), GraphQL se agregó en 2019. Desde entonces, el GraphQL API es el que recibe toda la inversión: nuevas features, mejor performance, bulk operations, introspección completa. El REST API está en modo mantenimiento. Esta sección enseña exclusivamente GraphQL.
Autenticación y scopes
El Admin API requiere el header X-Shopify-Access-Token con el access token del comerciante. Ese token se obtiene al completar el flujo OAuth de instalación. Para los scripts del tutorial lo obtienes directamente en Admin → Apps → tu app → API credentials → Admin API access token. Guárdalo en ~/proyectos/shopify/brew-atlas/brew-atlas-app/.env bajo la clave SHOPIFY_ADMIN_TOKEN. Este token es secreto de servidor — nunca lo expongas al cliente ni lo incluyas en el repositorio.
Los permisos que tu app puede ejercer se declaran en shopify.app.toml bajo la clave scopes:
# shopify.app.toml
[access_scopes]
scopes = "read_products,write_products,read_orders,read_customers"Cuando el comerciante instala la app, ve exactamente qué permisos está otorgando y acepta. Si tu app pide un scope que no declaró en el toml, la API rechaza la operación con un error de permisos — no HTTP 401, sino un userError o un error de schema.
Los scopes siguen el patrón read_X / write_X. write_X incluye implícitamente read_X. Los scopes disponibles cubren productos, pedidos, clientes, inventario, metafields, webhooks, páginas, y docenas de recursos más.
Queries del Admin API
La sintaxis de queries es GraphQL estándar. Lo que difiere es el schema — el Admin API expone el modelo completo de datos de la tienda, no solo la vista del comprador.
# Query: últimos 10 pedidos con cliente y total
query RecentOrders($first: Int!) {
orders(first: $first, reverse: true) {
edges {
node {
id
name
createdAt
displayFinancialStatus
totalPriceSet {
shopMoney {
amount
currencyCode
}
}
customer {
displayName
email
}
lineItems(first: 5) {
edges {
node {
title
quantity
originalUnitPriceSet {
shopMoney { amount currencyCode }
}
}
}
}
}
}
}
}La diferencia más visible respecto al Storefront API: los precios se representan como MoneyBag con shopMoney (en la moneda de la tienda) y presentmentMoney (en la moneda de presentación al cliente). Esto existe porque el Admin ve ambas monedas simultáneamente — el comerciante necesita saber cuánto cobró en cada moneda.
Mutations y el patrón userErrors
Todas las mutations del Admin API siguen el mismo patrón de respuesta: el recurso modificado y un array userErrors. Este array contiene errores de validación de negocio — condiciones que hacen que la operación sea inválida pero que no producen un error HTTP.
mutation ProductUpdate($input: ProductInput!) {
productUpdate(input: $input) {
product {
id
title
status
}
userErrors {
field
message
}
}
}Con variables:
{
"input": {
"id": "gid://shopify/Product/1234567890",
"title": "Geisha Washed — La Palma",
"status": "ACTIVE"
}
}Si la operación es exitosa, userErrors es un array vacío y product contiene el recurso actualizado. Si hay un error de validación (por ejemplo, un título que viola alguna regla de negocio), product es null y userErrors contiene los detalles. El HTTP status code es 200 en ambos casos.
Este patrón es consistente en todo el Admin API. Siempre verifica userErrors antes de asumir que la operación fue exitosa — no basta con que el HTTP status sea 200.
Paginación
El Admin API usa paginación basada en cursor. El patrón es first / after para avanzar, last / before para retroceder:
query Products($first: Int!, $after: String) {
products(first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
edges {
cursor
node {
id
title
}
}
}
}Para paginar, tomas el endCursor de la respuesta y lo pasas como after en la siguiente query. El pageInfo.hasNextPage te dice si hay más datos.
Para datasets grandes (miles o decenas de miles de registros), la paginación manual es lenta y costosa en términos de rate limiting. Para esos casos, usa bulk operations.
Rate limiting basado en costo
El Admin API no usa rate limiting por conteo de requests — usa un sistema de costo por query. Cada query tiene un costo calculado basado en la complejidad de los campos solicitados y la cantidad de objetos que puede retornar. El bucket por defecto es de 1000 puntos para tiendas estándar (más para Plus y cuentas de partner). El bucket se restaura a 50 puntos por segundo.
Cuando ejecutás una query, la respuesta incluye en extensions.cost el costo de esa query específica y el estado actual de tu bucket:
{
"data": { ... },
"extensions": {
"cost": {
"requestedQueryCost": 32,
"actualQueryCost": 15,
"throttleStatus": {
"maximumAvailable": 1000.0,
"currentlyAvailable": 985.0,
"restoreRate": 50.0
}
}
}
}requestedQueryCost es el costo calculado antes de ejecutar (basado en first: N). actualQueryCost es el costo real después de ejecutar (basado en cuántos objetos realmente devolvió). Shopify te cobra el costo real, no el estimado.
Cuando el bucket se agota, Shopify devuelve un error con el código THROTTLED y el campo extensions.cost.throttleStatus.restoreRate te dice cuántos puntos por segundo se van a restaurar. El header Retry-After indica cuántos segundos esperar. Implementá backoff exponencial en tu cliente.
Creá ~/proyectos/shopify/brew-atlas/scripts/utils/admin-client.ts:
// brew-atlas/scripts/utils/admin-client.ts
async function queryWithRetry(
client: ReturnType<typeof createAdminApiClient>,
query: string,
variables: Record<string, unknown>,
retries = 3
): Promise<unknown> {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const { data, errors } = await client.request(query, { variables });
if (errors?.length) throw new Error(JSON.stringify(errors));
return data;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('THROTTLED') && attempt < retries) {
const wait = Math.pow(2, attempt) * 1000;
await new Promise((r) => setTimeout(r, wait));
continue;
}
throw err;
}
}
}Bulk Operations
Para exportar o procesar miles o millones de registros, las bulk operations son el mecanismo correcto. En lugar de paginar manualmente, le pides a Shopify que ejecute la query en background y te entregue los resultados como un archivo JSONL descargable.
# Paso 1: iniciar la bulk operation
mutation {
bulkOperationRunQuery(
query: """
{
products {
edges {
node {
id
title
status
variants {
edges {
node {
id
sku
price
inventoryQuantity
}
}
}
}
}
}
}
"""
) {
bulkOperation {
id
status
}
userErrors {
field
message
}
}
}# Paso 2: consultar el estado (polling)
query BulkOperationStatus {
currentBulkOperation {
id
status
errorCode
objectCount
url
}
}Cuando status es COMPLETED, url contiene una URL pre-firmada de S3 desde donde puedes descargar el archivo JSONL. El archivo tiene un objeto por línea — productos, variantes, etc. — en el orden de la query.
Las bulk operations no consumen el rate limiting de costo — tienen su propio sistema de colas. Solo puede haber una bulk operation activa por tienda a la vez. Si inicias una segunda, la primera se cancela.
Para mutaciones masivas (actualizar miles de precios, metafields, etc.), existe bulkOperationRunMutation con un archivo de entrada JSONL.
Webhooks
Los webhooks permiten que Shopify notifique a tu app cuando ocurren eventos en la tienda. En lugar de hacer polling a la API cada N segundos para ver si hubo pedidos nuevos, Shopify llama a tu endpoint HTTP cuando un pedido se crea.
Declaración en shopify.app.toml (recomendado — el CLI los despliega automáticamente):
# shopify.app.toml
[[webhooks.subscriptions]]
topics = ["orders/create", "orders/updated"]
uri = "/webhooks"
[[webhooks.subscriptions]]
topics = ["products/update"]
uri = "/webhooks/products"O creación programática vía Admin API:
mutation WebhookSubscriptionCreate($topic: WebhookSubscriptionTopic!, $webhookSubscription: WebhookSubscriptionInput!) {
webhookSubscriptionCreate(topic: $topic, webhookSubscription: $webhookSubscription) {
webhookSubscription {
id
topic
endpoint {
... on WebhookHttpEndpoint {
callbackUrl
}
}
}
userErrors { field message }
}
}Validación de firma HMAC: Shopify firma cada webhook con HMAC-SHA256 usando el client secret de tu app. Tu endpoint debe validar esa firma antes de procesar el payload — de lo contrario, cualquiera podría enviar requests falsos a tu endpoint.
Creá ~/proyectos/shopify/brew-atlas/brew-atlas-app/app/webhooks/validate.ts:
// brew-atlas/app/webhooks/validate.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
export function validateWebhookSignature(
rawBody: string,
hmacHeader: string,
secret: string
): boolean {
const computed = createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('base64');
return timingSafeEqual(
Buffer.from(computed),
Buffer.from(hmacHeader)
);
}Usa timingSafeEqual siempre — la comparación de strings regular es vulnerable a timing attacks.
Puentes mentales
El Admin API es a Shopify lo que el endpoint privado de tu backend es a tu SPA. El token es secreto, el access es privilegiado, y las operaciones tienen consecuencias reales en los datos del comerciante. La responsabilidad de manejar ese poder correctamente es tuya.
El patrón userErrors es la respuesta de Shopify al problema de reportar errores de validación en GraphQL. En REST, un error de validación devuelve HTTP 400. En GraphQL, devolver HTTP 400 rompe la especificación — una respuesta GraphQL siempre es HTTP 200. La solución es userErrors como campo de primera clase en la respuesta: es el equivalente de FormikErrors, ValidationError[] de class-validator, o los errores de un resolver de GraphQL propio. Siempre verificas ese campo antes de asumir éxito.
Las bulk operations son el equivalente de lanzar un job de background en Rails o un worker en Node — procesamiento asíncrono para operaciones que llevarían demasiado tiempo en una sola request síncrona. La diferencia: Shopify corre el job por ti y solo tienes que consultar el estado y descargar el resultado.
Estado 2026
Lo relevante al 2026:
- Versión estable:
2026-04. El Admin API y el Storefront API comparten el mismo calendario de versiones y el mismo modelo de soporte (≥12 meses por versión estable). - REST deprecado en la práctica: el REST API sigue funcionando y recibe fixes de seguridad, pero las features nuevas (metaobjetos, bulk mutations, funciones) solo están en GraphQL. Para código nuevo, GraphQL siempre.
@shopify/admin-api-client: el cliente oficial de Shopify para Node. Maneja la serialización, los headers, y expone TypeScript types generados desde el schema. Úsalo en lugar de fetch crudo para reducir boilerplate.- Scopes granulares: desde 2024, algunos recursos tienen scopes más granulares. Revisá la documentación de cada recurso para ver el scope específico necesario.
Sintaxis y anatomía
Cliente de Admin API en TypeScript con el paquete oficial. Creá ~/proyectos/shopify/brew-atlas/scripts/sync-orders.ts:
// brew-atlas/scripts/sync-orders.ts
import { createAdminApiClient } from '@shopify/admin-api-client';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
const client = createAdminApiClient({
storeDomain: process.env.SHOPIFY_STORE ?? 'brew-atlas-dev.myshopify.com',
apiVersion: '2026-04',
accessToken: process.env.SHOPIFY_ADMIN_TOKEN!,
});
const ORDERS_QUERY = /* GraphQL */ `
query RecentOrders($first: Int!) {
orders(first: $first, reverse: true) {
edges {
node {
id
name
createdAt
displayFinancialStatus
totalPriceSet {
shopMoney { amount currencyCode }
}
customer {
displayName
email
}
}
}
}
}
`;
async function syncOrders() {
const { data, errors } = await client.request(ORDERS_QUERY, {
variables: { first: 50 },
});
if (errors) {
console.error('GraphQL errors:', errors);
process.exit(1);
}
const rows = data.orders.edges.map(({ node }: {
node: {
name: string;
createdAt: string;
displayFinancialStatus: string;
totalPriceSet: { shopMoney: { amount: string; currencyCode: string } };
customer: { displayName: string; email: string } | null;
};
}) => [
node.name,
node.createdAt,
node.displayFinancialStatus,
`${node.totalPriceSet.shopMoney.amount} ${node.totalPriceSet.shopMoney.currencyCode}`,
node.customer?.displayName ?? '',
node.customer?.email ?? '',
].join(','));
const csv = [
'order,created_at,status,total,customer_name,customer_email',
...rows,
].join('\n');
const outPath = join(import.meta.dirname, 'out', 'orders.csv');
writeFileSync(outPath, csv, 'utf-8');
console.log(`Exported ${rows.length} orders to ${outPath}`);
}
syncOrders().catch(console.error);Y un ejemplo de bulk operation para exportar todo el catálogo con variantes:
// Fragmento: iniciar bulk operation de productos
const BULK_MUTATION = /* GraphQL */ `
mutation {
bulkOperationRunQuery(
query: """
{
products {
edges {
node {
id
title
handle
status
variants {
edges {
node {
id
sku
price
inventoryQuantity
}
}
}
}
}
}
}
"""
) {
bulkOperation { id status }
userErrors { field message }
}
}
`;
const { data } = await client.request(BULK_MUTATION);
if (data.bulkOperationRunQuery.userErrors.length) {
throw new Error(JSON.stringify(data.bulkOperationRunQuery.userErrors));
}
console.log('Bulk operation started:', data.bulkOperationRunQuery.bulkOperation.id);
// Hacer polling a currentBulkOperation hasta status === 'COMPLETED'
// Luego descargar el JSONL desde data.currentBulkOperation.urlProyecto · Brew Atlas
El archivo ~/proyectos/shopify/brew-atlas/scripts/sync-orders.ts conecta con el Admin API y exporta los últimos 50 pedidos a ~/proyectos/shopify/brew-atlas/scripts/out/orders.csv. Es el script de reporting mínimo que cualquier operación de e-commerce necesita.
El token de Admin API se obtiene en Admin → Apps → tu app → API credentials → Admin API access token. Guárdalo en ~/proyectos/shopify/brew-atlas/brew-atlas-app/.env bajo la clave SHOPIFY_ADMIN_TOKEN. A diferencia del Storefront token, este token nunca sale del servidor ni va al repositorio.
Instala la dependencia desde ~/proyectos/shopify/brew-atlas/brew-atlas-app/:
npm install @shopify/admin-api-client
Crea el directorio de salida y ejecuta el script:
mkdir -p ~/proyectos/shopify/brew-atlas/scripts/out && npx tsx ~/proyectos/shopify/brew-atlas/scripts/sync-orders.ts
El CSV queda en ~/proyectos/shopify/brew-atlas/scripts/out/orders.csv.
Errores comunes
userErrors en las mutations devuelve HTTP 200 incluso cuando la operación falló. Este es el error de integración más frecuente: la app asume que HTTP 200 significa éxito, no verifica userErrors, y silencia fallos de validación que deberían ser visibles. Siempre verifica data.mutationName.userErrors.length === 0 antes de usar el recurso retornado.
El rate limiting del Admin API es basado en costo, no en conteo. Una sola query con first: 250 y múltiples relaciones anidadas puede costar 500+ puntos del bucket de 1000. Si tu app hace múltiples queries en paralelo sin controlar el costo, va a golpear el límite THROTTLED rápidamente. Monitorea extensions.cost.throttleStatus.currentlyAvailable e implementa backoff cuando baje de 200 puntos.
Los tokens del Admin API son secretos de servidor. Nunca los pongas en el repositorio, en variables de entorno de CI sin protección, ni en código del lado del cliente. Si un token se compromete, revocalo inmediatamente desde Admin → Apps → tu app → API credentials y generá uno nuevo. Los tokens comprometidos pueden usarse para extraer datos de clientes, modificar productos, o crear pedidos fraudulentos.
Para datasets grandes, siempre usá bulk operations en lugar de paginación manual. La paginación con first: 250 a través de 10.000 productos requiere 40 queries, consume el bucket de rate limiting, y puede tardar minutos. Una bulk operation corre en el background de Shopify y te entrega el resultado completo en un JSONL — es literalmente el mecanismo diseñado para esto.
El campo extensions.cost en las respuestas del Admin API es tu herramienta de profiling. Antes de poner una app en producción, ejecuta tus queries más frecuentes y revisa el costo real vs. el estimado. Si una query de listado cuesta 80+ puntos, considera si puedes reducir los campos solicitados o dividirla en queries más pequeñas. Un bucket de 1000 puntos que se restaura a 50/s te da ~20 requests costosas por segundo — planifica en consecuencia.
Checklist senior
- Puedes explicar la diferencia entre el Admin API y el Storefront API: qué expone cada uno, qué token usa cada uno, y por qué el token de Admin nunca sale del servidor
- Sabes que
userErrorsen las mutations es un error de validación, no un error HTTP, y siempre lo verificas - Entiendes el modelo de rate limiting basado en costo: bucket de 1000 puntos, restauración a 50/s, y el campo
extensions.costpara monitorear - Puedes implementar backoff exponencial cuando recibes un error
THROTTLED - Sabes cuándo usar paginación manual vs bulk operations, y cuál es el límite práctico donde cambiás de estrategia
- Puedes escribir una bulk operation completa: iniciar, hacer polling del estado, descargar el JSONL
- Sabes declarar webhooks en
shopify.app.tomly validar la firma HMAC contimingSafeEqual - Puedes usar
@shopify/admin-api-clientpara construir un cliente Admin API tipado en TypeScript
Quiz · ¿Lo tenés claro?
-
1. Una mutation de `productCreate` responde HTTP 200 y parece exitosa, pero el producto no aparece. ¿Qué olvidaste chequear?
En el Admin API los errores de validación no son HTTP 4xx — vienen en `userErrors` dentro del payload 200. Siempre verificar explícitamente. Es la causa #1 de 'parece funcionar pero no guarda'.
-
2. El Admin API usa rate limiting basado en costo. ¿Cómo funciona?
Leaky bucket: 1000 pts max, recupera 50/s, el costo real viene en la respuesta en `extensions.cost.{requestedQueryCost, actualQueryCost, throttleStatus}`. Al superar = error `THROTTLED` → backoff exponencial.
-
3. Necesitas procesar 500k productos. ¿Estrategia?
Bulk operations existen exactamente para este caso. La query se ejecuta en background, devuelve un JSONL (una línea por nodo), no cuenta igual al rate limit, y escala a millones. Paginación manual para 500k es anti-patrón.
-
4. Estás recibiendo un webhook `orders/create` con header `X-Shopify-Hmac-Sha256`. ¿Cómo validás la firma de forma segura?
Dos pitfalls: (1) hay que usar comparación de tiempo constante (timingSafeEqual) porque `===` filtra información por timing; (2) el body debe ser el raw, sin parsear — cualquier re-serialización cambia el hash.
-
5. Quieres declarar webhooks para tu app. ¿Lugar correcto en 2026?
Desde 2024, webhooks en `shopify.app.toml` es la ruta recomendada — los declara la app, el CLI los registra automáticamente al desplegar. Imperativo (`webhookSubscriptionCreate`) sigue siendo válido pero es caso especial.
Siguiente
Con las dos APIs de GraphQL cubiertas — Storefront para el comprador, Admin para el comerciante — el siguiente bloque entra en el mundo de las extensiones: cómo tu app inyecta UI en un theme estándar sin modificar el código del theme directamente. Eso es el territorio de las Theme App Extensions.