Saltar al contenido
ApiPay Hub · Docs

Referencia de SDKs#

Cinco paquetes, dos mundos que no se mezclan.

PaqueteRegistroVersiónRuntimeClave que usa
@apipay/nodenpm1.0.0Node.js 22 LTSsk_test_ / sk_live_
apipay/apipay-phpPackagist1.0.0PHP 8.3sk_test_ / sk_live_
apipayPyPI1.0.0Python 3.12sk_test_ / sk_live_
com.apipay:apipay-javaMaven Central1.0.0Java 17sk_test_ / sk_live_
@apipay/checkout-jsnpm + script tag1.0.0Navegadores evergreenpk_test_ / pk_live_

@apipay/checkout-js: el del navegador#

Sólo conoce la clave publicable y sólo monta un iframe. No habla con api.apipay.io, no autentica nada y no ve datos de tarjeta: la captura ocurre dentro del componente alojado de la pasarela (los Bricks de MercadoPago) o en el formulario de Transbank tras una redirección top-level.

<div id="apipay-checkout"></div>
<script src="https://checkout.apipay.io/sdk/v1.0.0/apipay.js" crossorigin="anonymous" defer></script>
<script defer>
  addEventListener('DOMContentLoaded', function () {
    ApiPay.init({ publicKey: 'pk_test_8Kq3ZmT2vXw1' }).checkout({
      clientSecret: window.__PI_SECRET__, // lo inyecta tu servidor al renderizar
      container: '#apipay-checkout',
      locale: 'es-CL',
      onSuccess: function (r) { location.assign('/gracias?pi=' + r.paymentIntentId); },
      onError: function (e) { mostrarError(e.code); },
      onCancel: function () { location.assign('/carro'); },
    });
  });
</script>
// La misma cosa vía npm, con tipos.
import { ApiPay } from '@apipay/checkout-js';

const handle = ApiPay.init({ publicKey: 'pk_test_8Kq3ZmT2vXw1' }).checkout({
  clientSecret,
  container: '#apipay-checkout',
});
// Idempotente: quita el listener de message y elimina el iframe.
handle.unmount();

Códigos de onError (decide siempre por code, nunca por message):

codeSignificadoQué hacer
payment_failedLa pasarela rechazó el pagoOfrecer otro medio y crear un intent nuevo
intent_expiredEl intent expiró antes de completarseCrear un intent nuevo
session_invalidclient_secret inválido, o el widget no respondió el handshake en 10 sRecargar la página
embed_origin_deniedEl dominio que embebe el checkout no está registradoRegistrarlo en el backoffice

Los cuatro server-side: superficie idéntica#

Mismos nombres lógicos, adaptados a la sintaxis de cada lenguaje. Si sabes usar uno, sabes usar los cuatro.

client = ApiPay(apiKey, baseUrl?, timeout?, maxRetries?, telemetry?)

client.paymentIntents.create(body, { idempotencyKey? })
client.paymentIntents.retrieve(id)
client.paymentIntents.confirm(id, body?)
client.paymentIntents.cancel(id, body?)
client.refunds.create(body, { idempotencyKey? })
client.refunds.retrieve(id)
client.transactions.list(params?)      -> iterador que pagina por cursor
client.webhooks.constructEvent(rawBody, signatureHeader, secret) -> evento tipado

Y las rutas reales del contrato detrás de cada método:

MétodoRutaAutenticaciónNota
POST/v1/payment-intentssk_Idempotency-Key obligatoria
GET/v1/payment-intents/{id}sk_ | pk_ + X-Client-SecretCon pk_ incluye el bloque checkout
POST/v1/payment-intents/{id}/confirmsk_ | pk_ + X-Client-SecretPuede devolver REQUIRES_ACTION con redirect_url
POST/v1/payment-intents/{id}/cancelsk_Solo desde CREATED y REQUIRES_ACTION
POST/v1/refundssk_Idempotency-Key obligatoria; sin amount_minor reembolsa el total
GET/v1/refunds/{id}sk_Lectura simple
GET/v1/transactionssk_Paginacion por cursor; el SDK la recorre por ti

Autenticación: header X-API-Key. Nunca Authorization: Bearer. El client_secret, cuando se usa, viaja siempre en el header X-Client-Secret, nunca como query param.

Instalación#

pnpm add @apipay/node
# npm i @apipay/node · yarn add @apipay/node

Construir el cliente#

import { ApiPay } from '@apipay/node';

// Un solo objeto por proceso: es inmutable y sin estado mas alla de su configuracion.
export const apipay = new ApiPay({
apiKey: process.env.APIPAY_SECRET_KEY ?? '',
timeout: 30_000,   // ms
maxRetries: 2,
telemetry: true,
});
OpciónDefaultNotas
apiKeyobligatoriaUna pk_ se rechaza localmente
baseUrlhttps://api.apipay.ioDebe ser http(s); se normaliza sin barra final
timeout30 sPor petición, no por operación completa
maxRetries2Reintentos sobre el intento inicial, y sólo para GET
telemetryactivadaUser-Agent: apipay-node/1.0.0 node/22.11.0. Con false, sólo apipay-node/1.0.0

La telemetría lleva versión de SDK y de runtime, nada más: ni nombre del tenant, ni URLs, ni datos del pagador. Es lo que permite avisar por email a los tenants afectados antes de retirar algo (ver la política de deprecación).


La capa artesanal#

Los modelos (PaymentIntent, Refund, Transaction, Event, Problem, los enums de estado) son la proyección directa del contrato OpenAPI y podrían generarse. Todo lo demás —transporte, reintentos, idempotencia, paginación, errores, verificación de firmas— se decidió una vez y se implementó cuatro veces. Esta es la parte que hay que entender para no llevarse una sorpresa en producción.

Reintentos: la asimetría GET / POST#

Es la decisión menos obvia de todo el diseño y la que más consultas de soporte evita.

Se reintentaNo se reintenta
GET ante 429 y 5xxCualquier POST del que se recibió respuesta
Errores de red ocurridos antes de enviar el requestCualquier POST del que no consta si llegó

Parámetros del backoff, idénticos en los cuatro:

ParámetroValor
EstrategiaExponencial con jitter completo
Base500 ms
Factor2
Tope por espera8 s
maxRetries2 (3 intentos en total)
Retry-After honrado en429 rate_limited y 503 gateway_unavailable, sólo ahí
Tope de Retry-After60 s

El tope de 60 s no es una cifra arbitraria. Retry-After la escribe un tercero: la API, sí, pero también cualquier proxy, balanceador o WAF del camino. Sin tope, un Retry-After: 3600 de un WAF mal configurado duerme un worker una hora, y con maxRetries=2, dos. Restringirlo a 429 y 503 es la otra mitad de la misma precaución: un 500 con la cabecera puesta por un proxy no debe gobernar la espera en lugar del backoff.

Idempotencia: la clave se genera y se te devuelve#

POST /v1/payment-intents y POST /v1/refunds exigen Idempotency-Key. Si no la pasas, el SDK genera una UUID v4 con el CSPRNG del lenguaje. En los dos casos te la devuelve junto al recurso, en un envoltorio que no forma parte del contrato:

// El resultado es el recurso del contrato + idempotencyKey.
const intent = await apipay.paymentIntents.create({
amount_minor: 1499000,
currency: 'CLP',
gateway_id: 'webpay',
});

await db.ordenes.update(orderId, {
intent_id: intent.id,
idempotency_key: intent.idempotencyKey, // persistirla ANTES de seguir
});

El ciclo completo del reintento seguro, que es lo que sustituye al reintento automático:

  1. Generas o dejas que el SDK genere la clave.
  2. La persistes junto a la orden antes de considerar la operación en marcha.
  3. Si no hay respuesta, reintentas el mismo POST con exactamente esa clave.
  4. La API devuelve la respuesta original en lugar de crear un segundo recurso, y marca la respuesta como réplica.
  5. Si otra petición con la misma clave está todavía en vuelo, responde 409 idempotency_key_in_flight: espera y reintenta, no cambies la clave.
  6. Si reutilizas la clave con un cuerpo distinto, responde 409 idempotency_key_reuse. Es un bug de tu lado, no de la API.

Las claves viven 24 h en la plataforma. Un límite de 255 caracteres.

Auto-paginación por cursor#

El consumidor nunca toca cursor ni next_cursor. list() devuelve un iterador que pide la página siguiente cuando hace falta y se detiene cuando has_more es false.

// Elemento a elemento:
for await (const txn of apipay.transactions.list({ status: 'APPROVED', limit: 50 })) {
await conciliar(txn);
}

// O pagina a pagina, cuando el proceso es por lotes:
for await (const page of apipay.transactions.list({ type: 'REFUND' }).pages()) {
await conciliarLote(page.data);
}

Los filtros son status, type, from, to y limit (1..100, default del servidor 20). cursor está deliberadamente ausente de la superficie: exponerlo invitaría a paginar a mano, que es exactamente lo que la auto-paginación elimina.

Errores tipados: escribe contra code#

Todo error HTTP se materializa como una excepción con cuatro datos: code (el identificador estable del catálogo), status, el documento problem+json completo y el requestId del header X-Request-Id.

Correspondencia code → clase, en los cuatro:

codeHTTPNode / PythonPHPJava
validation_error400ValidationErrorValidationExceptionValidationException
invalid_api_key401AuthenticationErrorAuthenticationExceptionAuthenticationException
invalid_client_secret401AuthenticationErrorAuthenticationExceptionAuthenticationException
invalid_webhook_signature401AuthenticationErrorAuthenticationExceptionAuthenticationException
card_declined402CardDeclinedErrorGatewayDeclinedExceptionCardDeclinedException
gateway_rejected402GatewayRejectedErrorGatewayDeclinedExceptionGatewayRejectedException
insufficient_permissions403InsufficientPermissionsErrorPermissionExceptionInsufficientPermissionsException
resource_not_found404ResourceNotFoundErrorNotFoundExceptionResourceNotFoundException
idempotency_key_reuse409IdempotencyErrorIdempotencyExceptionIdempotencyException
idempotency_key_in_flight409IdempotencyErrorIdempotencyExceptionIdempotencyException
invalid_state_transition409InvalidStateTransitionErrorInvalidStateTransitionExceptionInvalidStateTransitionException
currency_not_supported422CurrencyNotSupportedErrorValidationExceptionCurrencyNotSupportedException
amount_below_minimum422AmountBelowMinimumErrorValidationExceptionAmountBelowMinimumException
amount_exceeds_refundable422AmountExceedsRefundableErrorValidationExceptionAmountExceedsRefundableException
rate_limited429RateLimitErrorRateLimitExceptionRateLimitException
internal_error500InternalServerErrorServerExceptionInternalServerException
gateway_unavailable503GatewayUnavailableErrorServiceUnavailableExceptionGatewayUnavailableException

Además de las de API, hay excepciones que no vienen de una respuesta HTTP:

SituaciónNodePythonPHPJava
Configuración inválida (pk_, baseUrl, timeout)ConfigurationErrorConfigurationErrorConfigurationExceptionConfigurationException
Red agotada sin respuestaApiConnectionErrorAPIConnectionErrorApiConnectionExceptionApiConnectionException
Firma de webhook inválidaSignatureVerificationErrorSignatureVerificationErrorSignatureVerificationExceptionSignatureVerificationException
Cuerpo válido en firma pero no en formaInvalidPayloadErrorInvalidPayloadErrorApiPayExceptionInvalidPayloadException

Todas heredan de una raíz común (ApiPayError / ApiPayException), así que un catch de último recurso siempre es posible.

import {
ApiConnectionError,
ApiError,
CardDeclinedError,
GatewayUnavailableError,
} from '@apipay/node';

try {
const confirmado = await apipay.paymentIntents.confirm(intentId, {
  payment_method_token: token,
});
return confirmado.status;
} catch (error: unknown) {
if (error instanceof CardDeclinedError) {
  return 'ofrecer_otro_medio';
}
if (error instanceof GatewayUnavailableError) {
  encolarReintento(intentId, error.retryAfterSeconds);
  return 'reintentar_luego';
}
if (error instanceof ApiConnectionError) {
  // No consta si el confirm llego: NO reintentar, consultar.
  const actual = await apipay.paymentIntents.retrieve(intentId);
  return actual.status;
}
if (error instanceof ApiError) {
  logger.error({ code: error.code, status: error.status, request_id: error.requestId });
}
throw error;
}

Verificación de webhooks: en PHP es estática#

ApiPay-Signature: t=<unix>,v1=<hex> sobre "{t}." + cuerpo_crudo, HMAC-SHA256, comparación en tiempo constante, tolerancia 300 s y varios v1 aceptados para la rotación de secreto. Una firma inválida lanza, nunca devuelve un booleano que un if olvidado pueda ignorar.

LenguajeCómo se invoca
TypeScriptconstructEvent(raw, sig, secret) o apipay.webhooks.constructEvent(...)
Pythonconstruct_event(raw, sig, secret) o client.webhooks.construct_event(...)
Javanew Webhooks().constructEvent(raw, sig, secret) o client.webhooks()...
PHPApiPay\Webhooks::constructEvent($payload, $sig, $secret) — estática

El paso delicado es siempre el mismo: el cuerpo crudo. Se firman los bytes que llegaron; cualquier reserialización (un json_encode(json_decode(...)), un middleware que reordena claves) cambia los bytes y la firma deja de coincidir.

FrameworkCuerpo crudo
Expressexpress.raw({ type: 'application/json' }) en esa ruta
FastifyaddContentTypeParser('application/json', { parseAs: 'buffer' }, ...)
Next.js App Routerawait request.text()
Laravel$request->getContent()
PHP planofile_get_contents('php://input')
FastAPI / Starletteawait request.body()
Flaskrequest.get_data()
Djangorequest.body
Spring Boot@RequestBody byte[] rawBody
Servletrequest.getInputStream().readAllBytes()

Guía completa con deduplicación por evt_ en Webhooks; proyectos completos por framework en las recetas.

Dinero#

amount_minor es siempre un entero en unidades menores más currency ISO 4217. Ningún SDK convierte a unidades mayores por su cuenta y ninguno usa coma flotante. CLP tiene exponente 0: 1499000 son $1.499.000, no $14.990,00. El tipo es number entero en TS, int en PHP y Python, y long en Java.

PCI DSS SAQ A#

Ningún SDK acepta, transporta ni documenta datos de tarjeta: ni número, ni CVV, ni track data. Lo único que viaja en confirm es payment_method_token, un token emitido por la pasarela desde sus hosted fields.

payment_method de la respuesta es siempre null hoy, a propósito: ningún adaptador de pasarela captura metadatos de tarjeta —ni el last4— para no ampliar el alcance de cumplimiento. No construyas UI que dependa de ese campo.


Compatibilidad y estado real#

Versión del contrato@apipay/nodeapipay/apipay-phpapipaycom.apipay:apipay-java
/v1 (payment-api.v1.yaml)1.x1.x1.x1.x
/v2 (futuro)2.x2.x2.x2.x

SemVer independiente por SDK: un fix del cliente PHP no obliga a publicar nada en Python. La única regla estructural es que el major del contrato arrastra el major de los cuatro a la vez.

Estado verificado de la suite: 285 tests en verde en TypeScript (92), Python (165) y Java (28). El SDK de PHP se revisó por lectura exhaustiva pero su suite no se ha ejecutado todavía: hace falta una máquina con PHP 8.3 y Composer antes de publicar apipay/apipay-php.

Ver Changelog para la política de deprecación, la de soporte y los pendientes abiertos.