Referencia de SDKs#
Cinco paquetes, dos mundos que no se mezclan.
| Paquete | Registro | Versión | Runtime | Clave que usa |
|---|---|---|---|---|
@apipay/node | npm | 1.0.0 | Node.js 22 LTS | sk_test_ / sk_live_ |
apipay/apipay-php | Packagist | 1.0.0 | PHP 8.3 | sk_test_ / sk_live_ |
apipay | PyPI | 1.0.0 | Python 3.12 | sk_test_ / sk_live_ |
com.apipay:apipay-java | Maven Central | 1.0.0 | Java 17 | sk_test_ / sk_live_ |
@apipay/checkout-js | npm + script tag | 1.0.0 | Navegadores evergreen | pk_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):
code | Significado | Qué hacer |
|---|---|---|
payment_failed | La pasarela rechazó el pago | Ofrecer otro medio y crear un intent nuevo |
intent_expired | El intent expiró antes de completarse | Crear un intent nuevo |
session_invalid | client_secret inválido, o el widget no respondió el handshake en 10 s | Recargar la página |
embed_origin_denied | El dominio que embebe el checkout no está registrado | Registrarlo 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étodo | Ruta | Autenticación | Nota |
|---|---|---|---|
| POST | /v1/payment-intents | sk_ | Idempotency-Key obligatoria |
| GET | /v1/payment-intents/{id} | sk_ | pk_ + X-Client-Secret | Con pk_ incluye el bloque checkout |
| POST | /v1/payment-intents/{id}/confirm | sk_ | pk_ + X-Client-Secret | Puede devolver REQUIRES_ACTION con redirect_url |
| POST | /v1/payment-intents/{id}/cancel | sk_ | Solo desde CREATED y REQUIRES_ACTION |
| POST | /v1/refunds | sk_ | Idempotency-Key obligatoria; sin amount_minor reembolsa el total |
| GET | /v1/refunds/{id} | sk_ | Lectura simple |
| GET | /v1/transactions | sk_ | 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/nodeConstruir 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ón | Default | Notas |
|---|---|---|
apiKey | obligatoria | Una pk_ se rechaza localmente |
baseUrl | https://api.apipay.io | Debe ser http(s); se normaliza sin barra final |
timeout | 30 s | Por petición, no por operación completa |
maxRetries | 2 | Reintentos sobre el intento inicial, y sólo para GET |
telemetry | activada | User-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 reintenta | No se reintenta |
|---|---|
GET ante 429 y 5xx | Cualquier POST del que se recibió respuesta |
| Errores de red ocurridos antes de enviar el request | Cualquier POST del que no consta si llegó |
Parámetros del backoff, idénticos en los cuatro:
| Parámetro | Valor |
|---|---|
| Estrategia | Exponencial con jitter completo |
| Base | 500 ms |
| Factor | 2 |
| Tope por espera | 8 s |
maxRetries | 2 (3 intentos en total) |
Retry-After honrado en | 429 rate_limited y 503 gateway_unavailable, sólo ahí |
Tope de Retry-After | 60 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:
- Generas o dejas que el SDK genere la clave.
- La persistes junto a la orden antes de considerar la operación en marcha.
- Si no hay respuesta, reintentas el mismo
POSTcon exactamente esa clave. - La API devuelve la respuesta original en lugar de crear un segundo recurso, y marca la respuesta como réplica.
- 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. - 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:
code | HTTP | Node / Python | PHP | Java |
|---|---|---|---|---|
validation_error | 400 | ValidationError | ValidationException | ValidationException |
invalid_api_key | 401 | AuthenticationError | AuthenticationException | AuthenticationException |
invalid_client_secret | 401 | AuthenticationError | AuthenticationException | AuthenticationException |
invalid_webhook_signature | 401 | AuthenticationError | AuthenticationException | AuthenticationException |
card_declined | 402 | CardDeclinedError | GatewayDeclinedException | CardDeclinedException |
gateway_rejected | 402 | GatewayRejectedError | GatewayDeclinedException | GatewayRejectedException |
insufficient_permissions | 403 | InsufficientPermissionsError | PermissionException | InsufficientPermissionsException |
resource_not_found | 404 | ResourceNotFoundError | NotFoundException | ResourceNotFoundException |
idempotency_key_reuse | 409 | IdempotencyError | IdempotencyException | IdempotencyException |
idempotency_key_in_flight | 409 | IdempotencyError | IdempotencyException | IdempotencyException |
invalid_state_transition | 409 | InvalidStateTransitionError | InvalidStateTransitionException | InvalidStateTransitionException |
currency_not_supported | 422 | CurrencyNotSupportedError | ValidationException | CurrencyNotSupportedException |
amount_below_minimum | 422 | AmountBelowMinimumError | ValidationException | AmountBelowMinimumException |
amount_exceeds_refundable | 422 | AmountExceedsRefundableError | ValidationException | AmountExceedsRefundableException |
rate_limited | 429 | RateLimitError | RateLimitException | RateLimitException |
internal_error | 500 | InternalServerError | ServerException | InternalServerException |
gateway_unavailable | 503 | GatewayUnavailableError | ServiceUnavailableException | GatewayUnavailableException |
Además de las de API, hay excepciones que no vienen de una respuesta HTTP:
| Situación | Node | Python | PHP | Java |
|---|---|---|---|---|
Configuración inválida (pk_, baseUrl, timeout) | ConfigurationError | ConfigurationError | ConfigurationException | ConfigurationException |
| Red agotada sin respuesta | ApiConnectionError | APIConnectionError | ApiConnectionException | ApiConnectionException |
| Firma de webhook inválida | SignatureVerificationError | SignatureVerificationError | SignatureVerificationException | SignatureVerificationException |
| Cuerpo válido en firma pero no en forma | InvalidPayloadError | InvalidPayloadError | ApiPayException | InvalidPayloadException |
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.
| Lenguaje | Cómo se invoca |
|---|---|
| TypeScript | constructEvent(raw, sig, secret) o apipay.webhooks.constructEvent(...) |
| Python | construct_event(raw, sig, secret) o client.webhooks.construct_event(...) |
| Java | new Webhooks().constructEvent(raw, sig, secret) o client.webhooks()... |
| PHP | ApiPay\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.
| Framework | Cuerpo crudo |
|---|---|
| Express | express.raw({ type: 'application/json' }) en esa ruta |
| Fastify | addContentTypeParser('application/json', { parseAs: 'buffer' }, ...) |
| Next.js App Router | await request.text() |
| Laravel | $request->getContent() |
| PHP plano | file_get_contents('php://input') |
| FastAPI / Starlette | await request.body() |
| Flask | request.get_data() |
| Django | request.body |
| Spring Boot | @RequestBody byte[] rawBody |
| Servlet | request.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/node | apipay/apipay-php | apipay | com.apipay:apipay-java |
|---|---|---|---|---|
/v1 (payment-api.v1.yaml) | 1.x | 1.x | 1.x | 1.x |
/v2 (futuro) | 2.x | 2.x | 2.x | 2.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.