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
customerIdstringCliente con un método de pago almacenado
productCodestringCódigo de un producto one_time 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)
idempotencyKeystringNoLos reintentos con la misma clave nunca cobran dos veces
metadataobjectNoSe devuelve en el webhook charge.*
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": "1234567890",
    "productCode": "prod_credits100",
    "quantity": 2,
    "idempotencyKey": "order-789"
  }'

Respuesta (201):

json
{
  "success": true,
  "data": {
    "charge": {
      "status": "paid",
      "transactionId": "9876543210",
      "orderId": "ORD-12345-abc",
      "amount": 2000,
      "currency": "USD",
      "quantity": 2,
      "prorated": false,
      "externalChargeId": "ext_456"
    }
  },
  "message": "Charge succeeded"
}

amount es el monto realmente cobrado en centavos (relevante con prorate). Los rechazos devuelven "status": "failed" con failureReason (el texto del procesador) y, cuando el rechazo se mapea a una causa conocida, un failureCode normalizado — actualmente duplicate_transaction, que significa que la ventana de transacción duplicada del gateway rechazó una repetición del mismo monto y la tarjeta en sí no fue rechazada. También se emite un webhook charge.succeeded o charge.failed al webhook del proyecto con los mismos campos.

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
paid201El cargo se liquidó. externalChargeId es la referencia del procesador.
failed201La tarjeta fue rechazada — ver failureReason / failureCode.
processing202NoEl intento síncrono agotó el tiempo de forma no concluyente y Zelta Pay ahora reconcilia el cargo contra el gateway en segundo plano. Esto no es una falla — el resultado final se entrega por webhook (ver ).
unknownSolo alcanzable desde processing: la ventana de reconciliación venció sin una respuesta definitiva del gateway. El cargo puede o no haberse capturado, así que se marca para revisión manual — nunca lo trates como una falla limpia. Se emite un webhook charge.unknown para que sepas que no vendrá ningún resultado más.

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": "9876543210",
      "orderId": "ORD-12345-abc",
      "amount": 2000,
      "currency": "USD",
      "quantity": 2,
      "prorated": false
    }
  },
  "message": "Charge is processing; the final result will be delivered via webhook"
}

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. Un trabajo en segundo plano durable entonces consulta al gateway con backoff durante hasta ~2 minutos y liquida el cargo:

  • En cuanto el gateway da una respuesta final, el cargo pasa a paid o failed y se dispara el webhook charge.succeeded / charge.failed correspondiente — exactamente como una liquidación síncrona.
  • 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 30 segundos (sugerimos 60s) para que un cargo síncrono lento pero exitoso nunca se aborte de tu lado.
  • 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 devuelve de forma segura el cargo en curso en lugar de cobrar dos veces (puedes recibir ERR_CHARGE_IN_PROGRESS mientras aún se liquida — espera y reintenta, o consulta GET /v1/charges/:id).

Errores comunes:

CódigoSignificado
ERR_PRODUCT_NOT_ONE_TIMEEl producto es recurrente — usa suscripciones en su lugar
ERR_CUSTOMER_NO_VAULTEl cliente no tiene método de pago almacenado
ERR_NO_ACTIVE_SUBSCRIPTIONProducto addon, pero el cliente no tiene suscripción activa
ERR_PRORATE_NOT_ALLOWEDprorate: true en un producto que no requiere suscripción
ERR_SUBSCRIPTION_PERIOD_UNAVAILABLELa suscripción activa no tiene fronteras de período usables
ERR_PRORATED_AMOUNT_ZEROEl período actual ya terminó — reintenta tras la renovación o cobra sin prorate
ERR_CHARGE_IN_PROGRESSYa hay un cargo con esta clave de idempotencia liquidándose (reintenta más tarde)

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, y se acota a [0, 1].
  • 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: lim, off (paginación), paymentStatus (pending | processing | paid | failed | unknown — ver ), customerId y expand=customer,product.

Consultar GET /v1/charges/:id es la forma de leer el estado actual de un cargo que devolvió 202 processing, si lo prefieres a esperar el webhook.

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

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 en lugar de cobrar de nuevo. Las claves están namespaceadas por proyecto.

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