@Override
  public AssessmentResult verify(
      final String siteKey,
      final Action action,
      final String token,
      final String ip)
      throws IOException {

    final DynamicCaptchaConfiguration config = dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration();
    final String body = String.format("response=%s&secret=%s&remoteip=%s",
        URLEncoder.encode(token, StandardCharsets.UTF_8),
        URLEncoder.encode(this.apiKey, StandardCharsets.UTF_8),
        ip);
    final HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://hcaptcha.com/siteverify"))
        .header("Content-Type", "application/x-www-form-urlencoded")
        .POST(HttpRequest.BodyPublishers.ofString(body))
        .build();

    final HttpResponse<String> response;
    try {
      response = this.client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join();
    } catch (CompletionException e) {
      logger.warn("failed to make http request to hCaptcha: {}", e.getMessage());
      throw new IOException(ExceptionUtils.unwrap(e));
    }

    if (response.statusCode() != Response.Status.OK.getStatusCode()) {
      logger.warn("failure submitting token to hCaptcha (code={}): {}", response.statusCode(), response);
      throw new IOException("hCaptcha http failure : " + response.statusCode());
    }

    final HCaptchaResponse hCaptchaResponse = SystemMapper.jsonMapper()
        .readValue(response.body(), HCaptchaResponse.class);

    logger.debug("received hCaptcha response: {}", hCaptchaResponse);

    if (!hCaptchaResponse.success) {
      for (String errorCode : hCaptchaResponse.errorCodes) {
        Metrics.counter(INVALID_REASON_COUNTER_NAME,
            "action", action.getActionName(),
            "reason", errorCode).increment();
      }
      return AssessmentResult.invalid();
    }

    // hcaptcha uses the inverse scheme of recaptcha (for hcaptcha, a low score is less risky)
    final float score = 1.0f - hCaptchaResponse.score;
    if (score < 0.0f || score > 1.0f) {
      logger.error("Invalid score {} from hcaptcha response {}", hCaptchaResponse.score, hCaptchaResponse);
      return AssessmentResult.invalid();
    }
    final BigDecimal threshold = config.getScoreFloorByAction().getOrDefault(action, config.getScoreFloor());
    final AssessmentResult assessmentResult = AssessmentResult.fromScore(score, threshold.floatValue());

    for (String reason : hCaptchaResponse.scoreReasons) {
      Metrics.counter(ASSESSMENT_REASON_COUNTER_NAME,
          "action", action.getActionName(),
          "reason", reason,
          "score", assessmentResult.getScoreString()).increment();
    }
    return assessmentResult;
  }
@Test
  public void badBody() throws IOException, InterruptedException {
    final FaultTolerantHttpClient httpClient = mockResponder(200, """
        {"success" : true,
        """);
    final HCaptchaClient client = new HCaptchaClient("fake", httpClient, mockConfig(true, 0.5));
    assertThrows(IOException.class, () -> client.verify(SITE_KEY, Action.CHALLENGE, TOKEN, null));
  }