Receta: Spring Boot#
Un proyecto completo con Spring Boot 3.x y com.apipay:apipay-java: crea el intent, sirve la
plantilla del widget y recibe los webhooks leyendo el cuerpo como byte[].
mi-tienda/
├── build.gradle.kts
└── src/main/
├── java/com/tienda/
│ ├── TiendaApplication.java
│ ├── ApiPayProperties.java
│ ├── ApiPayConfig.java
│ ├── OrderStore.java
│ ├── CheckoutController.java
│ └── ApiPayWebhookController.java
└── resources/
├── application.yml
└── templates/checkout.html
build.gradle.kts#
plugins {
java
id("org.springframework.boot") version "3.5.0"
id("io.spring.dependency-management") version "1.1.7"
}
group = "com.tienda"
version = "0.0.1-SNAPSHOT"
java {
// El SDK tiene baseline Java 17 a proposito (lo consumen terceros).
// Cualquier version >= 17 sirve; aqui se usa 21 LTS.
toolchain { languageVersion.set(JavaLanguageVersion.of(21)) }
}
repositories { mavenCentral() }
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("com.apipay:apipay-java:1.0.0")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
tasks.test { useJUnitPlatform() }
Cualquier 3.x reciente de Spring Boot vale. El SDK trae una sola dependencia de runtime
(Jackson 2, que Spring ya arrastra) y su transporte es el HttpClient del JDK: no añade cliente HTTP
ni SDK de pasarela.
src/main/resources/application.yml#
server:
port: 8080
apipay:
# Backoffice > API keys. La sk_ jamas llega al navegador.
secret-key: ${APIPAY_SECRET_KEY}
public-key: ${APIPAY_PUBLIC_KEY}
# Backoffice > Webhooks. Se muestra UNA sola vez al crear el endpoint.
webhook-secret: ${APIPAY_WEBHOOK_SECRET}
# URL publica de esta app: la usan return_url y el endpoint de webhooks.
public-base-url: ${PUBLIC_BASE_URL:http://localhost:8080}
ApiPayProperties.java#
package com.tienda;
import org.springframework.boot.context.properties.ConfigurationProperties;
/** Configuracion de ApiPay. Ningun valor por defecto para los secretos: si falta, no arranca. */
@ConfigurationProperties(prefix = "apipay")
public record ApiPayProperties(
String secretKey,
String publicKey,
String webhookSecret,
String publicBaseUrl) {
public ApiPayProperties {
if (secretKey == null || secretKey.isBlank()) {
throw new IllegalStateException("apipay.secret-key is required");
}
if (webhookSecret == null || webhookSecret.isBlank()) {
throw new IllegalStateException("apipay.webhook-secret is required");
}
}
}
TiendaApplication.java#
package com.tienda;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class TiendaApplication {
public static void main(String[] args) {
SpringApplication.run(TiendaApplication.class, args);
}
}
ApiPayConfig.java#
package com.tienda;
import com.apipay.ApiPayClient;
import com.apipay.webhook.Webhooks;
import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ApiPayConfig {
/**
* Un cliente por aplicacion: es inmutable y sin estado mas alla de su configuracion.
* Una clave pk_ se rechaza aqui mismo, con ConfigurationException, y el contexto no arranca.
*/
@Bean
ApiPayClient apiPayClient(ApiPayProperties properties) {
return ApiPayClient.builder()
.apiKey(properties.secretKey())
.timeout(Duration.ofSeconds(30))
.maxRetries(2)
.build();
}
/** Verificacion de webhooks: no necesita API key, asi que es un bean aparte. */
@Bean
Webhooks apiPayWebhooks() {
return new Webhooks();
}
}
OrderStore.java#
package com.tienda;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Component;
/**
* Persistencia de juguete.
*
* <p>En produccion cada metodo es una consulta a la base de datos, y {@code markEventSeen} es un
* INSERT con UNIQUE sobre el id del evento dentro de la MISMA transaccion que el efecto de negocio:
* los reintentos de entrega pueden llegar a instancias distintas de la aplicacion.
*/
@Component
public class OrderStore {
/** Estado interno de la orden del comercio; no confundir con el estado del payment intent. */
public enum Status { PENDIENTE, PAGADA, FALLIDA, REEMBOLSADA }
/** Dinero: entero en unidades menores + divisa ISO 4217. Jamas double ni BigDecimal flotante. */
public static final class Order {
private final String reference;
private final long amountMinor;
private final String currency;
private volatile Status status = Status.PENDIENTE;
private volatile String intentId;
private volatile String clientSecret;
private volatile String idempotencyKey;
Order(String reference, long amountMinor, String currency) {
this.reference = reference;
this.amountMinor = amountMinor;
this.currency = currency;
}
public String reference() { return reference; }
public long amountMinor() { return amountMinor; }
public String currency() { return currency; }
public Status status() { return status; }
public String intentId() { return intentId; }
public String clientSecret() { return clientSecret; }
public String idempotencyKey() { return idempotencyKey; }
void attachIntent(String intentId, String clientSecret, String idempotencyKey) {
this.intentId = intentId;
this.clientSecret = clientSecret;
this.idempotencyKey = idempotencyKey;
}
void status(Status status) { this.status = status; }
}
private final Map<String, Order> orders = new ConcurrentHashMap<>();
private final Set<String> processedEvents = ConcurrentHashMap.newKeySet();
public Order findOrCreate(String reference, long amountMinor, String currency) {
return orders.computeIfAbsent(reference, key -> new Order(key, amountMinor, currency));
}
public void attachIntent(Order order, String intentId, String clientSecret, String idempotencyKey) {
order.attachIntent(intentId, clientSecret, idempotencyKey);
}
public Optional<Order> byIntentId(String intentId) {
return orders.values().stream()
.filter(order -> intentId.equals(order.intentId()))
.findFirst();
}
/** {@code true} la primera vez que se ve el evento; {@code false} en los reintentos. */
public boolean markEventSeen(String eventId) {
return processedEvents.add(eventId);
}
public void markStatus(String intentId, Status status) {
byIntentId(intentId).ifPresent(order -> order.status(status));
}
}
CheckoutController.java#
package com.tienda;
import com.apipay.ApiPayClient;
import com.apipay.exception.ApiConnectionException;
import com.apipay.exception.ApiException;
import com.apipay.model.CreatePaymentIntentRequest;
import com.apipay.model.IdempotentResult;
import com.apipay.model.PaymentIntent;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.server.ResponseStatusException;
@Controller
public class CheckoutController {
/** Terminado en 00: la pasarela sandbox aprueba. Prueba con 05, 13 y 42. */
private static final long AMOUNT_MINOR = 1_499_000L;
private static final String CURRENCY = "CLP";
private final ApiPayClient apipay;
private final ApiPayProperties properties;
private final OrderStore store;
// Constructor injection, nunca @Autowired en campos.
public CheckoutController(ApiPayClient apipay, ApiPayProperties properties, OrderStore store) {
this.apipay = apipay;
this.properties = properties;
this.store = store;
}
/**
* Crea el intent si hace falta y sirve la pagina del widget.
*
* <p>Esta misma ruta es la pagina de RETORNO de las pasarelas con redireccion: se sirve otra vez
* con el mismo client_secret y el widget dispara el segundo confirm con el token_ws que
* @apipay/checkout-js copio de la URL del comercio.
*/
@GetMapping("/checkout/{reference}")
public String checkout(@PathVariable String reference, Model model) {
OrderStore.Order order = store.findOrCreate(reference, AMOUNT_MINOR, CURRENCY);
if (order.clientSecret() == null) {
crearIntent(order);
}
model.addAttribute("order", order);
model.addAttribute("publicKey", properties.publicKey());
model.addAttribute("clientSecret", order.clientSecret());
return "checkout";
}
private void crearIntent(OrderStore.Order order) {
CreatePaymentIntentRequest request =
CreatePaymentIntentRequest.builder(order.amountMinor(), order.currency())
.gatewayId("sandbox")
.description("Orden " + order.reference())
.customerEmail("cliente@example.com")
.returnUrl(properties.publicBaseUrl() + "/checkout/" + order.reference())
.metadata("order_id", order.reference())
.build();
try {
IdempotentResult<PaymentIntent> created = apipay.paymentIntents().create(request);
PaymentIntent intent = created.resource();
// Persistir la clave ANTES de seguir: es lo unico que hace seguro reintentar
// el POST del cobro. El SDK no reintenta POST por diseno.
store.attachIntent(
order,
intent.id(),
intent.clientSecretIfPresent().orElse(null),
created.idempotencyKey());
} catch (ApiConnectionException e) {
// No consta si llego. NO se reintenta a ciegas: se reintenta con la MISMA clave,
// que aqui no conocemos porque el SDK la genero y no hubo respuesta. Lo correcto
// es fijar la clave nosotros cuando queremos poder reintentar (ver mas abajo).
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE, "La pasarela no respondio; reintenta.", e);
} catch (ApiException e) {
// La logica se escribe contra e.code(), nunca contra e.detail().
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY, "No pudimos iniciar el pago: " + e.code(), e);
}
}
@GetMapping("/gracias")
public String gracias(@RequestParam("pi") String intentId, Model model) {
// Un GET si se reintenta solo ante 429 y 5xx, con backoff y jitter completo.
PaymentIntent intent = apipay.paymentIntents().retrieve(intentId);
model.addAttribute("intent", intent);
model.addAttribute(
"orderStatus",
store.byIntentId(intent.id()).map(o -> o.status().name()).orElse("DESCONOCIDA"));
return "gracias";
}
}
ApiPayWebhookController.java#
package com.tienda;
import com.apipay.exception.SignatureVerificationException;
import com.apipay.model.Event;
import com.apipay.model.PaymentIntent;
import com.apipay.model.Refund;
import com.apipay.webhook.WebhookSignatureVerifier;
import com.apipay.webhook.Webhooks;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ApiPayWebhookController {
private static final Logger log = LoggerFactory.getLogger(ApiPayWebhookController.class);
private final Webhooks webhooks;
private final ApiPayProperties properties;
private final OrderStore store;
public ApiPayWebhookController(Webhooks webhooks, ApiPayProperties properties, OrderStore store) {
this.webhooks = webhooks;
this.properties = properties;
this.store = store;
}
// consumes = ALL_VALUE: el cuerpo llega como bytes, sin que Jackson lo toque.
@PostMapping(path = "/webhooks/apipay", consumes = MediaType.ALL_VALUE)
public ResponseEntity<Void> receive(
@RequestBody byte[] rawBody,
@RequestHeader(name = WebhookSignatureVerifier.SIGNATURE_HEADER, required = false)
String signature) {
Event event;
try {
// Verifica t= y v1= (HMAC-SHA256 sobre "{t}." + bytes, tolerancia 300 s,
// varios v1 durante la rotacion, comparacion en tiempo constante).
event = webhooks.constructEvent(rawBody, signature, properties.webhookSecret());
} catch (SignatureVerificationException e) {
// 400 y nada mas: no se filtra por que fallo.
log.warn("rejected apipay webhook: {}", e.getMessage());
return ResponseEntity.badRequest().build();
}
// Deduplicar por evt_ ANTES de tocar la orden: la entrega es at-least-once y la
// plataforma reintenta hasta cinco veces (1m, 5m, 30m, 2h, 12h).
if (!store.markEventSeen(event.id())) {
return ResponseEntity.ok().build();
}
switch (event.type()) {
case Event.Types.PAYMENT_INTENT_SUCCEEDED -> {
PaymentIntent intent = webhooks.paymentIntentFrom(event);
store.markStatus(intent.id(), OrderStore.Status.PAGADA);
}
case Event.Types.PAYMENT_INTENT_FAILED,
Event.Types.PAYMENT_INTENT_EXPIRED,
Event.Types.PAYMENT_INTENT_CANCELED -> {
PaymentIntent intent = webhooks.paymentIntentFrom(event);
store.markStatus(intent.id(), OrderStore.Status.FALLIDA);
}
case Event.Types.REFUND_SUCCEEDED -> {
Refund refund = webhooks.refundFrom(event);
store.markStatus(refund.paymentIntentId(), OrderStore.Status.REEMBOLSADA);
}
// Un tipo nuevo del catalogo es un cambio ADITIVO: ignorar sin fallar.
default -> log.debug("ignored apipay event type {}", event.type());
}
// 2xx rapido. Emails, facturacion y ERP van a una cola.
return ResponseEntity.ok().build();
}
}
src/main/resources/templates/checkout.html#
<!doctype html>
<html lang="es-CL" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title th:text="'Pagar orden ' + ${order.reference()}">Pagar</title>
</head>
<body>
<h1 th:text="'Orden ' + ${order.reference()}">Orden</h1>
<p th:text="${order.amountMinor()} + ' ' + ${order.currency()}">Total</p>
<!-- PCI DSS SAQ A: esta plantilla NO tiene ningun campo de tarjeta. La captura
ocurre dentro del iframe alojado de la pasarela. -->
<p id="apipay-error" role="alert"></p>
<div id="apipay-checkout"></div>
<script
src="https://checkout.apipay.io/sdk/v1.0.0/apipay.js"
crossorigin="anonymous"
defer
></script>
<script th:inline="javascript">
/*<![CDATA[*/
// Thymeleaf serializa estos valores de forma segura: nada de concatenar a mano.
var apipayPublicKey = /*[[${publicKey}]]*/ 'pk_test_placeholder';
var apipayClientSecret = /*[[${clientSecret}]]*/ '';
/*]]>*/
addEventListener('DOMContentLoaded', function () {
var errorBox = document.querySelector('#apipay-error');
ApiPay.init({ publicKey: apipayPublicKey }).checkout({
clientSecret: apipayClientSecret,
container: '#apipay-checkout',
locale: 'es-CL',
onSuccess: function (r) {
location.assign('/gracias?pi=' + encodeURIComponent(r.paymentIntentId));
},
onError: function (e) {
// Decidir SIEMPRE por code, nunca por el texto del mensaje.
errorBox.textContent =
e.code === 'intent_expired' || e.code === 'session_invalid'
? 'La sesion de pago expiro. Recarga la pagina.'
: 'No pudimos procesar el pago. Prueba con otro medio.';
},
onCancel: function () {
location.assign('/carro');
},
});
});
</script>
</body>
</html>
src/main/resources/templates/gracias.html#
<!doctype html>
<html lang="es-CL" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8" />
<title>Gracias</title>
</head>
<body>
<h1>Gracias</h1>
<p>
Pago <code th:text="${intent.id()}">pi_</code> en estado
<strong th:text="${intent.status().value()}">?</strong>.
</p>
<p>Orden: <strong th:text="${orderStatus}">?</strong> (la confirma el webhook).</p>
</body>
</html>
Arrancar#
export APIPAY_SECRET_KEY=sk_test_...
export APIPAY_PUBLIC_KEY=pk_test_...
export APIPAY_WEBHOOK_SECRET=whsec_test_...
export PUBLIC_BASE_URL=http://localhost:8080
./gradlew bootRun
# En otra terminal, expon el puerto para que la plataforma alcance tu webhook:
# cloudflared tunnel --url http://localhost:8080
# ...y registra <la-url-publica>/webhooks/apipay en el backoffice.
Abre http://localhost:8080/checkout/4831 y paga.
Probar los cuatro desenlaces#
La pasarela sandbox decide por los dos últimos dígitos de amount_minor. Cambia
AMOUNT_MINOR y usa otra referencia:
AMOUNT_MINOR | Qué pasa | Estado final |
|---|---|---|
1_499_000L (…00) | Aprueba de inmediato | SUCCEEDED |
1_499_005L (…05) | 402 card_declined en el confirm | FAILED |
1_499_013L (…13) | Timeout; resuelve por webhook a los 60 s | FAILED |
1_499_042L (…42) | REQUIRES_ACTION con redirect_url | SUCCEEDED o FAILED |
Conciliación con Stream y cursor#
Un job de conciliación no toca cursor ni next_cursor jamás:
import com.apipay.model.TransactionListParams;
import com.apipay.model.TransactionStatus;
import java.time.Duration;
import java.time.Instant;
TransactionListParams params = TransactionListParams.builder()
.status(TransactionStatus.APPROVED)
.from(Instant.now().minus(Duration.ofDays(1)))
.limit(100)
.build();
// Perezoso: pide la pagina siguiente solo cuando el consumidor la necesita.
apipay.transactions().stream(params)
.forEach(txn -> conciliar(txn.id(), txn.amountMinor(), txn.currency()));
Errores comunes en este stack#
| Síntoma | Causa |
|---|---|
La firma nunca cuadra y rawBody.length == 0 | Un filtro consumió el InputStream antes del controlador |
La firma nunca cuadra con rawBody no vacío | Se usó @RequestBody String y el contenedor reinterpretó la codificación |
403 en el webhook | Spring Security con CSRF activo sobre esa ruta |
415 Unsupported Media Type | Falta consumes = MediaType.ALL_VALUE |
SignatureVerificationException sólo en producción | Reloj del servidor desfasado más de 300 s |
| El contexto no arranca | Falta APIPAY_SECRET_KEY o APIPAY_WEBHOOK_SECRET, o se puso una pk_ |
Siguientes pasos#
- Webhooks — rotación de secreto, reintentos y deduplicación.
- Referencia de SDKs — la asimetría
GET/POSTde reintentos. - Modo test — la tabla completa de
sandbox.