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.

Crear un cargo
POST /v1/chargesCuerpo de la solicitud:
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
customerId | string | Sí | Cliente del proyecto con un método de pago almacenado |
productCode | string | Sí | Código de un producto one_time activo del proyecto |
quantity | integer | No | Unidades a cobrar (por defecto 1, máx 1000) |
prorate | boolean | No | Solo productos addon: cobra solo la fracción restante del período actual de la suscripción (ver ) |
isTest | boolean | No | Entorno sandbox (por defecto false). Debe coincidir con el entorno en que se guardó la tarjeta del cliente |
idempotencyKey | string | No | 1 a 255 caracteres. Los reintentos con la misma clave nunca cobran dos veces (ver ) |
metadata | object | No | Llaves 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 |
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):
{
"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:
status | HTTP | ¿Terminal? | Significado |
|---|---|---|---|
paid | 201 | Sí | El cargo se liquidó. externalChargeId es la referencia del procesador. |
failed | 201 | Sí | La tarjeta fue rechazada — ver failureReason / failureCode. |
deferred | 201 | Sí | 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 |
processing | 202 | No | El 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:
{
"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
paidofailedy se dispara el webhookcharge.succeeded/charge.failedcorrespondiente. Este webhook no incluyemetadata. - 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 llamafailed) y se emite un webhookcharge.unknown. Ese evento es tu señal de que no vendrá ningún resultado más para este cargo — deja de esperarcharge.succeeded/charge.failedy 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 processingcomo un resultado normal y esperado: persistetransactionId/orderId, muestra el cargo como pendiente en tu UI y resuelve el resultado final desde el webhookcharge.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ó (paidofailed) recibes su resultado, y si todavía se está liquidando recibes409conERR_CHARGE_IN_PROGRESS— espera el webhook o reintenta más tarde.
Errores comunes:
| Código | HTTP | Significado |
|---|---|---|
ERR_PRODUCT_NOT_FOUND | 404 | El productCode no existe en el proyecto |
ERR_PRODUCT_NOT_ONE_TIME | 400 | El producto es recurrente — usa suscripciones en su lugar |
ERR_PRODUCT_NOT_ACTIVE | 400 | El producto no está activo |
ERR_CUSTOMER_NOT_FOUND | 404 | El cliente no existe en el proyecto |
ERR_CUSTOMER_NO_VAULT | 400 | El cliente no tiene método de pago almacenado |
ERR_NO_ACTIVE_SUBSCRIPTION | 400 | Producto addon, pero el cliente no tiene una suscripción active |
ERR_PRORATE_NOT_ALLOWED | 400 | prorate: true en un producto que no requiere suscripción |
ERR_SUBSCRIPTION_PERIOD_UNAVAILABLE | 400 | La suscripción activa no tiene fronteras de período usables |
ERR_PRORATED_AMOUNT_ZERO | 400 | El período actual ya terminó — reintenta tras la renovación o cobra sin prorate |
ERR_AMOUNT_BELOW_NMI_MINIMUM | 400 | El 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_ITEM | 400 | El 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_PROGRESS | 409 | Ya 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_PROVIDER | 404 | La 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ónpast_dueno 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
GET /v1/charges
GET /v1/charges/:idParámetros de consulta del endpoint de listado:
| Parámetro | Descripción |
|---|---|
lim, off | Paginación (lim de 1 a 100) |
paymentStatus | pending | processing | paid | failed | unknown | deferred (ver ) |
customerId | Solo los cargos de ese cliente |
expand | customer, product o customer,product. Cualquier otro valor devuelve 400 con ERR_INVALID_RELATIONS |
fields | Lista 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.
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:
| Campo | Tipo | Descripción |
|---|---|---|
id | string | Id del cargo: el transactionId de la respuesta de POST /v1/charges |
orderId | string | Referencia de la orden, la misma de la respuesta de POST y de los webhooks charge.* |
paymentStatus | string | pending, processing, paid, failed, unknown o deferred (ver ) |
transactionAmount | integer | Monto en centavos |
transactionCurrency | string | Moneda, por ejemplo USD |
quantity | integer | null | Unidades del producto cobradas |
projectId | string | Proyecto del cargo |
customerId | string | Cliente al que se cobró |
productId | string | null | Producto cobrado |
subscriptionId | string | null | Suscripción a la que se vincula el cargo (cargos de addons y renovaciones) |
invoiceId | string | null | Factura de la suscripción, cuando el cargo es una renovación |
paymentLinkId | string | null | Link de pago de cliente del que viene el cargo, si aplica |
customerEmail | string | null | Correo del cliente al momento del cargo |
externalPaymentId | string | null | Referencia del procesador (externalChargeId en la respuesta de POST) |
paymentMethod | string | null | Por ejemplo credit_card |
paymentProvider | string | null | Procesador que hizo el cargo: nmi o emetec |
paymentFailureReason | string | null | Texto del rechazo del procesador (failureReason en la respuesta de POST) |
paidAt | string | null | Fecha en que se pagó (ISO 8601) |
failedAt | string | null | Fecha en que falló (ISO 8601) |
createdAt | string | Fecha de creación (ISO 8601) |
updatedAt | string | Fecha de última actualización (ISO 8601) |
customer | object | Solo con expand=customer: un |
product | object | Solo con expand=product: el producto del catálogo |
{
"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/chargespara 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.failedycharge.unknown