# Java and Spring Boot with the CurrencyCore API

> Call /v1/convert and /v1/rates from Spring Boot 3 with RestClient, typed records, connect and read timeouts, and status-preserving errors.

Canonical: https://currency-core.com/docs/guides/java-spring/
Updated: 2026-11-11
Published: 2026-11-11

---
Spring Boot 3.2 or later includes `RestClient`, added in Spring Framework 6.1. `RestTemplate` still works but is in maintenance mode. `RestClient` shares Spring MVC's converters, so records and `BigDecimal` map without custom deserializers. It sets connect and read timeouts and preserves upstream status.

Targets Java 17 and Boot 3.2 or later. `RestClient` ships in `spring-web`, so one starter is enough.

The official Kotlin/JVM SDK is usable from Java, but this page uses Spring's `RestClient` so the client participates in Spring dependency injection, timeouts, and error handling instead of adding a second HTTP stack.

```groovy
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
}
```

Bind the key and base URL with a properties record:

```java
@ConfigurationProperties("currencycore")
public record CurrencyCoreProperties(String baseUrl, String apiKey) {}
```

```yaml
currencycore:
  base-url: https://api.currency-core.com/v1
  api-key: ${CURRENCYCORE_API_KEY}
```

The response record follows the envelope. `requestedDate` appears only when a different snapshot was served, and `asOf` lists carried-forward quotes:

```java
public record ConvertResponse(
        String from,
        BigDecimal amount,
        String date,
        String requestedDate,
        Map<String, String> asOf,
        List<Result> results) {

    public record Result(String to, BigDecimal result, BigDecimal rate) {}
}
```

Build one client per application. The JDK `HttpClient` owns the connect timeout and pool; the factory adds the read timeout:

```java
@Configuration
@EnableConfigurationProperties(CurrencyCoreProperties.class)
class CurrencyCoreConfig {

    @Bean
    @Qualifier("currencyCoreRestClient")
    RestClient currencyCoreRestClient(CurrencyCoreProperties props) {
        HttpClient jdk = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(3))
                .build();

        JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(jdk);
        factory.setReadTimeout(Duration.ofSeconds(10));

        return RestClient.builder()
                .baseUrl(props.baseUrl())
                .requestFactory(factory)
                .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + props.apiKey())
                .build();
    }
}
```

```java
@Component
public class CurrencyCoreClient {

    private final RestClient http;

    CurrencyCoreClient(@Qualifier("currencyCoreRestClient") RestClient http) {
        this.http = http;
    }

    public ConvertResponse convert(String from, String to, BigDecimal amount, LocalDate date) {
        return http.get()
                .uri(builder -> builder.path("/convert")
                        .queryParam("from", from)
                        .queryParam("to", to)
                        .queryParam("amount", amount.toPlainString())
                        .queryParam("date", date)
                        .build())
                .retrieve()
                .body(ConvertResponse.class);
    }
}
```

`RestClient` throws `RestClientResponseException` for 4xx and 5xx. Keep the body's code and the HTTP status:

```java
@RestControllerAdvice
class CurrencyCoreExceptionHandler {

    private final ObjectMapper json;

    CurrencyCoreExceptionHandler(ObjectMapper json) {
        this.json = json;
    }

    @ExceptionHandler(RestClientResponseException.class)
    ResponseEntity<ErrorEnvelope> apiError(RestClientResponseException ex) {
        ErrorEnvelope body;
        try {
            body = json.readValue(ex.getResponseBodyAsString(), ErrorEnvelope.class);
        } catch (JsonProcessingException e) {
            body = new ErrorEnvelope(
                    new ApiError("upstream_error", "HTTP " + ex.getStatusCode().value()));
        }
        return ResponseEntity.status(ex.getStatusCode()).body(body);
    }

    @ExceptionHandler(ResourceAccessException.class)
    ResponseEntity<ErrorEnvelope> transportError(ResourceAccessException ex) {
        return ResponseEntity.status(HttpStatus.GATEWAY_TIMEOUT)
                .body(new ErrorEnvelope(
                        new ApiError("upstream_timeout", "CurrencyCore did not respond")));
    }
}

record ErrorEnvelope(ApiError error) {}
record ApiError(String code, String message) {}
```

A conversion returns one entry per target, while `/v1/rates/{base}` returns a map with the base at `1`:

```json
{
  "from": "USD",
  "amount": 100,
  "date": "2026-11-10",
  "results": [{ "to": "INR", "result": 8345.0, "rate": 83.45 }]
}
```

```json
{ "base": "EUR", "date": "2026-11-10", "rates": { "EUR": 1, "USD": 1.0861 } }
```

The Free plan covers 1,000 requests per month at 60 per minute. Test keys share the same data and quota, always capped at 60 per minute; live keys require a paid plan.

## Production notes

`RestClient` blocks its thread. On Java 21, `spring.threads.virtual.enabled=true` lets requests wait on virtual threads, which raises concurrency, not upstream speed. Code holding a `synchronized` lock across I/O can still pin them.

Reuse the client and its JDK pool. A client per request discards pooled connections and TLS sessions and defeats your timeouts.

The API sends no `ETag`, `Last-Modified`, or quota headers, so conditional GETs save nothing. Cache rates by base and date, and track monthly usage yourself. Central-bank reference rates are polled hourly, but daily fixings normally change once per business day. Honor `Retry-After` on `429` when present; otherwise back off.

The SDK list has a Kotlin/JVM client on JitPack, not a Java SDK; this page uses Spring directly.

## Next steps

- [API reference](/docs/api/) and [authentication](/docs/authentication/)
- [Error codes](/docs/errors/) and [all guides](/docs/guides/)
- [Retries guide](/docs/guides/retries/)
