Skip to content

Cargos (API) ​

Un cargo cobra a un cliente vaulteado por un producto one_time. El monto siempre es product.price × quantity.

  • Productos independientes (p. ej. créditos): se cobran directamente.
  • Productos addon (requiresSubscription: true): el cliente debe tener una suscripción activa en el proyecto; el cargo se vincula a ella.

Todos los endpoints requieren tu API key de proyecto en el header X-API-Key.

Cargos de un proyecto con producto, cliente, monto y estado

Crear un cargo ​

http
POST /v1/charges

Cuerpo de la solicitud:

CampoTipoRequeridoDescripción
customerIdstringSíCliente del proyecto con un método de pago almacenado
productCodestringSíCódigo de un producto one_time activo del proyecto
quantityintegerNoUnidades a cobrar (por defecto 1, máx 1000)
proratebooleanNoSolo productos addon: cobra solo la fracción restante del período actual de la suscripción (ver )
isTestbooleanNoEntorno sandbox (por defecto false). Debe coincidir con el entorno en que se guardó la tarjeta del cliente
idempotencyKeystringNo1 a 255 caracteres. Los reintentos con la misma clave nunca cobran dos veces (ver )
metadataobjectNoLlaves de hasta 40 caracteres; valores string (máx. 255), number, boolean o null. No se guarda: se incluye en el webhook charge.* que se emite al resolver el cargo en la misma solicitud
bash
curl -X POST https://api-pay.zelta.dev/v1/charges \
  -H "X-API-Key: tu-api-key-de-proyecto" \
  -H "Content-Type: application/json" \
  -d '{
    "customerId": "7315555666677778888",
    "productCode": "prod_CREDITS100",
    "quantity": 2,
    "idempotencyKey": "order-789"
  }'

Respuesta (201):

json
{
  "success": true,
  "data": {
    "charge": {
      "status": "paid",
      "transactionId": "7318888999900001111",
      "orderId": "7K2M9QX4LB-543",
      "amount": 2000,
      "currency": "USD",
      "quantity": 2,
      "prorated": false,
      "externalChargeId": "ext_456",
      "paidAt": "2026-06-04T10:30:00.000Z"
    }
  },
  "message": "Charge succeeded",
  "timestamp": "2026-06-04T10:30:00.000Z"
}

amount es el monto realmente cobrado en centavos (relevante con prorate). Los rechazos devuelven 201 con "status": "failed", el mensaje "Charge failed", failureReason (el texto del procesador) y, cuando el rechazo se mapea a una causa conocida, un failureCode normalizado — actualmente duplicate_transaction (solo NMI), que significa que el procesador lo consideró un duplicado de un cobro reciente y la tarjeta en sí no fue rechazada. También se emite un webhook o al webhook del proyecto; en el webhook el ID del cargo va en charge.id (el transactionId de la respuesta) y se agregan productCode, customerId, subscriptionId e isTest.

Estados de un cargo ​

En el caso común, un cargo se resuelve de forma síncrona y la respuesta HTTP ya lleva el resultado final:

statusHTTP¿Terminal?Significado
paid201SíEl cargo se liquidó. externalChargeId es la referencia del procesador.
failed201SíLa tarjeta fue rechazada — ver failureReason / failureCode.
deferred201SíCuando el monto final de un addon es menor a $1.00, la tarjeta del cliente está en NMI y el addon es un item de la suscripción activa del cliente: el cargo se aprobó sin mover dinero y su monto se difirió a la próxima factura de la suscripción. Mensaje "Charge approved and deferred to the subscription's next invoice". Se emite un webhook charge.deferred. La respuesta incluye subscriptionId, deferredQuantity y reason. Si el addon no es un item de la suscripción, la solicitud falla con ERR_NO_DEFERRABLE_ITEM; para un producto que no es addon, con ERR_AMOUNT_BELOW_NMI_MINIMUM
processing202NoEl intento síncrono agotó el tiempo de forma no concluyente y Zelta Pay sigue verificando el cargo con el procesador. Esto no es una falla — el resultado final se entrega por webhook (ver ).
unknown—SíLa verificación terminó sin una respuesta definitiva del procesador. El cargo puede o no haberse capturado, así que se marca para revisión manual — nunca lo trates como una falla limpia. Cuando viene de processing, se emite un webhook charge.unknown para que sepas que no vendrá ningún resultado más.

Un cargo también puede quedar en pending si la solicitud se interrumpió antes de terminar (por ejemplo, si nuestra conexión con el procesador se cortó). Zelta Pay lo verifica más tarde y lo resuelve.

Un 201 siempre significa que el veredicto es final. Un 202 processing significa que debes esperar el webhook charge.succeeded / charge.failed (o consultar ) para conocer el resultado:

json
{
  "success": true,
  "data": {
    "charge": {
      "status": "processing",
      "transactionId": "7318888999900001111",
      "orderId": "7K2M9QX4LB-543",
      "amount": 2000,
      "currency": "USD",
      "quantity": 2,
      "prorated": false
    }
  },
  "message": "Charge is processing; the final result will be delivered via webhook",
  "timestamp": "2026-06-04T10:30:00.000Z"
}

Timeouts y cuánto esperar ​

Zelta Pay limita cada llamada al procesador de tarjetas a 25 segundos. Los cargos normalmente se liquidan bien dentro de ese margen, así que POST /v1/charges devuelve 201 con paid o failed en un solo viaje.

Si el procesador no responde dentro de esos 25s y una consulta de estado inmediata sigue siendo no concluyente, la solicitud devuelve 202 con status: "processing" en lugar de colgarse o adivinar. Zelta Pay sigue consultando al procesador durante unos 2 minutos y liquida el cargo:

  • En cuanto el procesador da una respuesta final, el cargo pasa a paid o failed y se dispara el webhook charge.succeeded / charge.failed correspondiente. Este webhook no incluye metadata.
  • Si toda la ventana de ~2 minutos pasa sin respuesta definitiva, el cargo se marca unknown (el dinero podría haberse movido, así que no se llama failed) y se emite un webhook charge.unknown. Ese evento es tu señal de que no vendrá ningún resultado más para este cargo — deja de esperar charge.succeeded / charge.failed y márcalo para revisión manual.

Recomendaciones para tu cliente HTTP:

  • Configura el timeout de tu solicitud en al menos 60 segundos: antes de responder 202, la solicitud puede esperar al procesador y hacer una consulta de estado, de hasta 25 segundos cada una.
  • Trata 202 processing como un resultado normal y esperado: persiste transactionId / orderId, muestra el cargo como pendiente en tu UI y resuelve el resultado final desde el webhook charge.succeeded / charge.failed, no desde la respuesta HTTP.
  • Siempre envía un idempotencyKey. Si tu propio cliente agota el tiempo antes de que Zelta Pay responda, reintentar con la misma clave nunca cobra dos veces: si el cargo ya terminó (paid o failed) recibes su resultado, y si todavía se está liquidando recibes 409 con ERR_CHARGE_IN_PROGRESS — espera el webhook o reintenta más tarde.

Errores comunes:

CódigoHTTPSignificado
ERR_PRODUCT_NOT_FOUND404El productCode no existe en el proyecto
ERR_PRODUCT_NOT_ONE_TIME400El producto es recurrente — usa suscripciones en su lugar
ERR_PRODUCT_NOT_ACTIVE400El producto no está activo
ERR_CUSTOMER_NOT_FOUND404El cliente no existe en el proyecto
ERR_CUSTOMER_NO_VAULT400El cliente no tiene método de pago almacenado
ERR_NO_ACTIVE_SUBSCRIPTION400Producto addon, pero el cliente no tiene una suscripción active
ERR_PRORATE_NOT_ALLOWED400prorate: true en un producto que no requiere suscripción
ERR_SUBSCRIPTION_PERIOD_UNAVAILABLE400La suscripción activa no tiene fronteras de período usables
ERR_PRORATED_AMOUNT_ZERO400El período actual ya terminó — reintenta tras la renovación o cobra sin prorate
ERR_AMOUNT_BELOW_NMI_MINIMUM400El monto final es menor a $1.00 con una tarjeta de NMI y no hay una suscripción a la cual diferirlo
ERR_NO_DEFERRABLE_ITEM400El monto final de un addon es menor a $1.00 con una tarjeta de NMI, pero el addon no es un item de la suscripción
ERR_CHARGE_IN_PROGRESS409Ya hay un cargo con esta clave de idempotencia liquidándose (reintenta más tarde)
ERR_NO_ACTIVE_NMI_SANDBOX_PAYMENT_PROVIDER / ERR_NO_ACTIVE_NMI_PRODUCTION_PAYMENT_PROVIDER404La tarjeta del cliente está en NMI y NMI no está activo en el entorno del cargo

Proración ​

Para productos addon puedes pasar prorate: true de modo que el cliente solo pague por el resto de su período de facturación actual:

amount = round(price × quantity × remainingFraction)
remainingFraction = (currentPeriodEnd − now) / (currentPeriodEnd − currentPeriodStart)
  • La fracción se calcula contra la suscripción activa a la que el cargo se vincula (si el cliente tiene varias, la creada más recientemente), y se acota a [0, 1]. Una suscripción past_due no cuenta como activa.
  • Ejemplo: un addon de $10.00 comprado a mitad de un ciclo de 30 días cobra $5.00 ("amount": 500, "prorated": true).
  • Justo después de una renovación la fracción es ~1 (precio completo); al final del período se acerca a 0 — si redondea a cero, la solicitud se rechaza con ERR_PRORATED_AMOUNT_ZERO.
  • La proración es un ajuste único: el addon no se agrega a futuras facturas y no se renueva.

El webhook charge.succeeded lleva el amount real y "prorated": true para que tu software pueda reconciliar.

Listar cargos ​

http
GET /v1/charges
GET /v1/charges/:id

Parámetros de consulta del endpoint de listado:

ParámetroDescripción
lim, offPaginación (lim de 1 a 100)
paymentStatuspending | processing | paid | failed | unknown | deferred (ver )
customerIdSolo los cargos de ese cliente
expandcustomer, product o customer,product. Cualquier otro valor devuelve 400 con ERR_INVALID_RELATIONS
fieldsLista separada por comas de campos del para recibir solo esos. Un campo que no pertenece al objeto cargo devuelve 400 con ERR_INVALID_FIELDS

El listado responde { charges, total } y GET /v1/charges/:id responde { charge }. El listado incluye también los cobros de renovaciones de suscripciones y los pagos de links de pago de cliente del proyecto.

Consultar GET /v1/charges/:id (con el transactionId de la respuesta) es la forma de leer el estado actual de un cargo que devolvió 202 processing, si lo prefieres a esperar el webhook. Si no existe en el proyecto, responde 404 con ERR_CHARGE_NOT_FOUND.

bash
curl "https://api-pay.zelta.dev/v1/charges/7318888999900001111" \
  -H "X-API-Key: tu-api-key-de-proyecto"

El objeto cargo ​

GET /v1/charges y GET /v1/charges/:id devuelven siempre estos campos, y ningún otro:

CampoTipoDescripción
idstringId del cargo: el transactionId de la respuesta de POST /v1/charges
orderIdstringReferencia de la orden, la misma de la respuesta de POST y de los webhooks charge.*
paymentStatusstringpending, processing, paid, failed, unknown o deferred (ver )
transactionAmountintegerMonto en centavos
transactionCurrencystringMoneda, por ejemplo USD
quantityinteger | nullUnidades del producto cobradas
projectIdstringProyecto del cargo
customerIdstringCliente al que se cobró
productIdstring | nullProducto cobrado
subscriptionIdstring | nullSuscripción a la que se vincula el cargo (cargos de addons y renovaciones)
invoiceIdstring | nullFactura de la suscripción, cuando el cargo es una renovación
paymentLinkIdstring | nullLink de pago de cliente del que viene el cargo, si aplica
customerEmailstring | nullCorreo del cliente al momento del cargo
externalPaymentIdstring | nullReferencia del procesador (externalChargeId en la respuesta de POST)
paymentMethodstring | nullPor ejemplo credit_card
paymentProviderstring | nullProcesador que hizo el cargo: nmi o emetec
paymentFailureReasonstring | nullTexto del rechazo del procesador (failureReason en la respuesta de POST)
paidAtstring | nullFecha en que se pagó (ISO 8601)
failedAtstring | nullFecha en que falló (ISO 8601)
createdAtstringFecha de creación (ISO 8601)
updatedAtstringFecha de última actualización (ISO 8601)
customerobjectSolo con expand=customer: un
productobjectSolo con expand=product: el producto del catálogo
json
{
  "success": true,
  "data": {
    "charge": {
      "id": "7318888999900001111",
      "orderId": "7K2M9QX4LB-543",
      "paymentStatus": "paid",
      "transactionAmount": 2000,
      "transactionCurrency": "USD",
      "quantity": 2,
      "projectId": "7301234567890123456",
      "customerId": "7315555666677778888",
      "productId": "7314444555566667770",
      "subscriptionId": null,
      "invoiceId": null,
      "paymentLinkId": null,
      "customerEmail": "[email protected]",
      "externalPaymentId": "ext_456",
      "paymentMethod": "credit_card",
      "paymentProvider": "nmi",
      "paymentFailureReason": null,
      "paidAt": "2026-06-04T10:30:00.000Z",
      "failedAt": null,
      "createdAt": "2026-06-04T10:29:58.000Z",
      "updatedAt": "2026-06-04T10:30:00.000Z"
    }
  },
  "message": "Operation completed successfully",
  "timestamp": "2026-06-04T10:31:00.000Z"
}

Los campos de lectura tienen otros nombres

Los campos del objeto cargo no se llaman igual que en la respuesta de POST /v1/charges: el estado va en paymentStatus, el monto en transactionAmount, la moneda en transactionCurrency, la referencia del procesador en externalPaymentId y el motivo del rechazo en paymentFailureReason.

Idempotencia ​

Pasa idempotencyKey en POST /v1/charges para hacer seguros los reintentos: si se envía la misma clave dos veces, la segunda solicitud devuelve el resultado del primer cargo (si ya es paid o failed) en lugar de cobrar de nuevo, o 409 ERR_CHARGE_IN_PROGRESS si el primero aún se está liquidando. Las claves son por proyecto.

  • La clave no compara el cuerpo: reutilizarla con otro producto o cantidad devuelve el primer cargo
  • Las validaciones (producto, cliente, suscripción, proración) se hacen antes de buscar la clave, así que un reintento puede fallar con un error de validación aunque el cargo original haya salido bien; si eso pasa, consulta GET /v1/charges para ver el cargo original

Siguientes pasos ​

  • — planes recurrentes y activación de addons a mitad de período
  • — los clientes vaulteados a los que cobras
  • — charge.succeeded, charge.failed y charge.unknown

Documentación oficial de Zelta