Saltar al contenido
ApiPay Hub · Docs

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_MINORQué pasaEstado final
1_499_000L (…00)Aprueba de inmediatoSUCCEEDED
1_499_005L (…05)402 card_declined en el confirmFAILED
1_499_013L (…13)Timeout; resuelve por webhook a los 60 sFAILED
1_499_042L (…42)REQUIRES_ACTION con redirect_urlSUCCEEDED 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íntomaCausa
La firma nunca cuadra y rawBody.length == 0Un filtro consumió el InputStream antes del controlador
La firma nunca cuadra con rawBody no vacíoSe usó @RequestBody String y el contenedor reinterpretó la codificación
403 en el webhookSpring Security con CSRF activo sobre esa ruta
415 Unsupported Media TypeFalta consumes = MediaType.ALL_VALUE
SignatureVerificationException sólo en producciónReloj del servidor desfasado más de 300 s
El contexto no arrancaFalta APIPAY_SECRET_KEY o APIPAY_WEBHOOK_SECRET, o se puso una pk_

Siguientes pasos#