Kotlin SDK

The official Kotlin SDK is built on coroutines: every operation is a suspend function, with typed models and typed exceptions. It runs on JVM 11 or later and avoids java.net.http and java.time, so it suits Android too (its tests run on the JVM). It needs a Kotlin 2.2+ compiler and depends only on kotlinx-coroutines-core and kotlinx-serialization-json.

Not published yet. The Maven artifact org.email-validation:email-validation-sdk-kotlin is not on Maven Central yet, so the install step below does not work today. The SDK is complete and tested; it will be published once the registry account and the licence are in place. Until then, use the code samples, which call the API directly.

Install

Gradle (Kotlin DSL):

dependencies {
    implementation("org.email-validation:email-validation-sdk-kotlin:1.0.0")
}

Maven:

<dependency>
  <groupId>org.email-validation</groupId>
  <artifactId>email-validation-sdk-kotlin</artifactId>
  <version>1.0.0</version>
</dependency>

The package is org.email_validation.kotlin.

Validate an address

import org.email_validation.kotlin.EmailValidationClient

suspend fun main() {
    EmailValidationClient(System.getenv("EV_API_KEY")).use { client ->
        val result = client.validate("accepted@sandbox.email-validation.org")
        println("${result.status} ${result.reason} ${result.action}") // valid accepted send
    }
}

Construct the client once and share it: it is safe to use from many coroutines and threads. Named parameters: ipAddress, timeout (seconds the check may take, 5–15) and fresh. fresh = true checks the address again even when it was checked in the last 12 months (see repeat checks). status, reason and action are value classes with named constants (Status.VALID, Action.REMOVE); a value added to the API later is kept as its raw string, so keep an else branch in when.

Async and cancellation

coroutineScope {
    val a = async { client.validate("accepted@sandbox.email-validation.org") }
    val b = async { client.validate("catch_all@sandbox.email-validation.org") }
    listOf(a.await(), b.await()).forEach { println("${it.email} ${it.status}") }
}

Keep concurrency within your rate limit; for long lists use a bulk job. Cancelling the calling coroutine, or withTimeout around a call, stops it at once with the standard CancellationException, also during a retry wait, and it is never retried.

Handle errors

try {
    client.validate(address)
} catch (e: InsufficientCreditsException) {
    // buy credits; nothing was charged
} catch (e: RateLimitException) {
    println("retry in ${e.retryAfter ?: 1} seconds")
} catch (e: EmailValidationException) {
    println("${e.code} ${e.status} ${e.requestId}")
}

Every error extends the unchecked EmailValidationException with status (0 when no answer arrived), code, requestId and retryAfter. Subclasses: InvalidRequestException, AuthenticationException, InsufficientCreditsException, PermissionDeniedException, NotFoundException, ConflictException, RateLimitException, ServiceUnavailableException, ServerException and ConnectionException.

Retries and timeouts

val client = EmailValidationClient(System.getenv("EV_API_KEY")) {
    timeout = 30.seconds      // per attempt
    maxRetries = 2            // after 429 / 503 answers
    maxRetryWait = 10.seconds // longer Retry-After values are thrown to you instead of waited out
}

429 and 503 answers are retried with backoff, honouring Retry-After. A daily key cap is never retried, and a validation is never retried after a network failure, because it may already have been charged.

Credits, usage and status

val credits = client.credits() // balance, debt, pending, lots
val usage = client.usage(from = "2026-10-19", to = "2026-11-30", groupBy = UsageGroupBy.DAY)
val status = client.status()

Bulk jobs

var job = client.bulk.create(listOf("accepted@sandbox.email-validation.org", "catch_all@sandbox.email-validation.org"))

// or from a CSV file (up to 100 MB)
job = client.bulk.create(File("customers.csv").readBytes(), BulkContentType.CSV, columnName = "email", hasHeader = true)

job = client.bulk.wait(job.id) { println("${it.status} ${it.processed}") }

if (job.status == BulkJobStatus.COMPLETED) {
    val csv: String = client.bulk.resultsText(job.id, filter = listOf("valid", "risky"))
} else if (job.status == BulkJobStatus.READY) {
    // it could not start: job.errorCode is e.g. insufficient_credits (buy credits, then client.bulk.start(job.id))
}

wait returns when the job is completed, failed, cancelled or expired, or when it is still ready with an errorCode: it could not start and does not move on by itself. Pass timeout = to give up earlier. Also: bulk.get, bulk.start for jobs created with start = false (call it before wait), bulk.cancel, bulk.results (the bytes) and bulk.resultsStream, an InputStream for large files.

Verify webhooks

fun handle(rawBody: ByteArray, headers: Map<String, String>): Int {
    val event = try {
        verifyWebhook(rawBody, headers, System.getenv("EV_WEBHOOK_SECRET"))
    } catch (e: WebhookVerificationException) {
        return 400
    }
    if (event.type == WebhookEventType.BULK_COMPLETED) println("job done ${event.bulkData().jobId}")
    return 204
}

Header names match in any case, and Map<String, List<String>> header maps work too. event.type can also be webhook.test, sent when you press Send test in the dashboard: answer it with a 2xx like any other event. signWebhook creates a valid signature for local tests.

Constants

STATUSES, REASONS, REASONS_BY_STATUS, REASON_INFO, ACTIONS, ERROR_CODES, WEBHOOK_EVENTS, the value classes Status, Reason and Action, and sandboxAddress(reason) mirror the API's vocabulary. See statuses and reasons and test addresses.

Bring your own HTTP client

The default UrlConnectionTransport uses HttpURLConnection on Dispatchers.IO and never follows redirects. To use OkHttp, Ktor or a fake in your tests, implement the Transport interface (suspend fun send(request): HttpResponse) and set it in the builder: EmailValidationClient(key) { transport = MyTransport() }.

Updated · Maintained by the email-validation.org team