openapi: 3.1.0 info: title: ZI² Verify API version: "1.0.0" summary: Real-time email validation, certified receipts, and bulk verification. description: | The ZI² Verify public developer API validates email addresses in real time, returns a cryptographically signed **Certified Receipt** for every result, and runs bulk verification jobs asynchronously. ## Authentication Every endpoint requires an API key sent as an HTTP Bearer token: ``` Authorization: Bearer zi2v_live__ ``` Keys carry scopes. Read endpoints require `validate:read`; validation and batch endpoints require `validate:write`. A key missing the required scope is rejected with `403 FORBIDDEN` and `details.missingScope`. ## Idempotency Both write endpoints (`POST /v1/validate`, `POST /v1/batch`) honour an optional `Idempotency-Key` request header (8–128 characters). A repeated request with the same key and the same body replays the original response and adds the header `Idempotent-Replay: true`. Reusing a key with a **different** body returns `409 CONFLICT`. Records are retained for 24 hours. If the store is unavailable the request passes through normally (fail-open). ## Credits - `POST /v1/validate` costs **1 credit**, charged after the engine returns. - `POST /v1/batch` reserves **N credits** at enqueue (N = count of unique, lower-cased emails); the unused portion is refunded when the job ends. Requests that would exceed your balance are rejected with `400 BAD_REQUEST`. ## Errors Every non-2xx response uses a stable envelope: ```json { "error": { "code": "BAD_REQUEST", "message": "…", "details": { } } } ``` contact: name: ZI² Verify url: https://verify.zi2.app servers: - url: https://verify.zi2.app description: Production tags: - name: Validation description: Single and bulk email validation. - name: Jobs description: Async batch job status and result downloads. - name: Account description: Credit balance and validation history. security: - bearerAuth: [] paths: /v1/validate: post: tags: [Validation] operationId: validateEmail summary: Validate a single email address description: | Validates one email address synchronously and returns a signed Certified Receipt. Requires the `validate:write` scope. Costs 1 credit, charged only after the validation engine returns a result. Rate limit: 500 requests/minute. Set `cache: true` to reuse this account's own recent probe of the same address instead of re-probing (still costs 1 credit and is still recorded in history). By default every call performs a fresh probe. parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ValidateRequest' examples: default: summary: Fresh probe value: email: jane.doe@gmail.com cached: summary: Allow a recent cache hit value: email: jane.doe@gmail.com cache: true responses: '200': description: Certified Receipt for the validated address. headers: X-Credits-Remaining: description: Remaining credit balance after this validation (fresh-probe path). schema: type: integer Idempotent-Replay: description: Present and set to `true` when this response was replayed for a repeated Idempotency-Key. schema: type: string enum: ["true"] content: application/json: schema: $ref: '#/components/schemas/CertifiedReceipt' examples: valid: summary: Valid address value: email: jane.doe@gmail.com status: valid score: 88 checks: reason: ok syntax: true mx: true domain: gmail.com mx_host: gmail-smtp-in.l.google.com is_disposable: false is_role_based: false is_free_provider: true is_catch_all: false spf: strict dkim: found dmarc: found mta_sts: true zi2_certified: false auth: spf: strict dkim: found dmarc: found dmarc_policy: reject zi2_certified: false zi2_trust_level: 0 zi2_key_id: "" mta_sts: true tls_rpt: false dane: false dnssec: true bimi: false bimi_vmc: false caa: true fcrdns: true ptr: mail-sor-f41.google.com dmarc_subdomain_policy: reject dmarc_alignment_spf: r dmarc_alignment_dkim: r dmarc_rua_set: true tls_version: TLSv1.3 tls_cipher: TLS_AES_256_GCM_SHA384 abuse_mailbox: false postmaster_mailbox: false helo_matches_rdns: true dnsbl_clean: true dnsbl_lists_on: [] dnswl_listed: false domain_age_days: 9200 probed_at: 2026-07-30T12:00:05.000Z latency_ms: 742 kid: v1 sig: 3q2-7w9k...signature...base64url credits_remaining: 4819 cached: false '400': description: Insufficient credits or invalid request body. When credits are insufficient, `details.currentCredits` reports the current balance. content: application/json: schema: $ref: '#/components/schemas/Error' examples: insufficientCredits: summary: Not enough credits value: error: code: BAD_REQUEST message: Insufficient credits (need 1, have 0) details: currentCredits: 0 validation: summary: Body validation failed value: error: code: BAD_REQUEST message: Request validation failed details: issues: - path: email message: Invalid email '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' /v1/batch: post: tags: [Validation] operationId: createBatch summary: Queue a bulk validation job description: | Queues an asynchronous job to validate up to 100,000 email addresses. Requires the `validate:write` scope. Emails are lower-cased and de-duplicated; credits are reserved for the count of **unique** addresses and the unused portion is refunded when the job finishes. Rate limit: 30 requests/minute. Maximum request body size: 8 MB. Optionally supply a `webhookUrl` to receive a `batch.completed` callback (see the webhooks section) and a `listName` to label the job. parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/BatchRequest' examples: default: summary: Verify a list with a completion webhook value: emails: - jane.doe@gmail.com - sales@example.com - bounce@mailinator.com webhookUrl: https://app.example.com/hooks/zi2verify listName: Newsletter Q3 responses: '202': description: Job accepted and queued. headers: Idempotent-Replay: description: Present and set to `true` when this response was replayed for a repeated Idempotency-Key. schema: type: string enum: ["true"] content: application/json: schema: $ref: '#/components/schemas/BatchAccepted' examples: queued: value: job_id: 6f1d2c9a-6b1e-4f0a-9e2a-1b2c3d4e5f60 status: queued credits_reserved: 3 progress: processed: 0 total: 3 status_url: /v1/jobs/6f1d2c9a-6b1e-4f0a-9e2a-1b2c3d4e5f60 '400': description: Insufficient credits or invalid request body. On insufficient credits, `details` reports `currentCredits`, `needed`, and `deltaShortfall`. content: application/json: schema: $ref: '#/components/schemas/Error' examples: insufficientCredits: summary: Not enough credits for the reservation value: error: code: BAD_REQUEST message: Insufficient credits for this batch details: currentCredits: 10 needed: 3 deltaShortfall: -7 '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' /v1/jobs/{id}: get: tags: [Jobs] operationId: getJob summary: Get batch job status description: | Returns the status and progress of a batch job owned by the calling account. Requires the `validate:read` scope. `results_url` is populated only once the job has `completed`. parameters: - $ref: '#/components/parameters/JobId' responses: '200': description: Job status. content: application/json: schema: $ref: '#/components/schemas/BatchJob' examples: completed: value: job_id: 6f1d2c9a-6b1e-4f0a-9e2a-1b2c3d4e5f60 status: completed progress: processed: 3 total: 3 credits_reserved: 3 created_at: 2026-07-30T12:00:00.000Z started_at: 2026-07-30T12:00:01.000Z completed_at: 2026-07-30T12:00:09.000Z results_url: /v1/jobs/6f1d2c9a-6b1e-4f0a-9e2a-1b2c3d4e5f60/results.csv running: value: job_id: 6f1d2c9a-6b1e-4f0a-9e2a-1b2c3d4e5f60 status: running progress: processed: 1 total: 3 credits_reserved: 3 created_at: 2026-07-30T12:00:00.000Z started_at: 2026-07-30T12:00:01.000Z completed_at: null results_url: null '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' /v1/jobs/{id}/results.csv: get: tags: [Jobs] operationId: getJobResultsCsv summary: Download batch results as CSV description: | Streams a completed job's results as a CSV attachment. Requires the `validate:read` scope. Returns `400 BAD_REQUEST` if the job has not yet completed. Columns, in order: `email,status,score,is_disposable,is_role_based,is_catch_all,is_free_provider,mx_host,latency_ms,probed_at` parameters: - $ref: '#/components/parameters/JobId' responses: '200': description: CSV file of validation results. headers: Content-Disposition: description: Attachment filename, e.g. `attachment; filename="zi2verify-.csv"`. schema: type: string content: text/csv: schema: type: string examples: csv: summary: Results CSV value: | email,status,score,is_disposable,is_role_based,is_catch_all,is_free_provider,mx_host,latency_ms,probed_at jane.doe@gmail.com,valid,88,false,false,false,true,gmail-smtp-in.l.google.com,742,2026-07-30T12:00:05.000Z sales@example.com,risky,58,false,true,false,false,mx.example.com,690,2026-07-30T12:00:06.000Z bounce@mailinator.com,disposable,5,true,false,false,false,,12,2026-07-30T12:00:07.000Z '400': description: Job is not completed yet. content: application/json: schema: $ref: '#/components/schemas/Error' examples: notCompleted: value: error: code: BAD_REQUEST message: Job is running — results available only after completion '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' /v1/balance: get: tags: [Account] operationId: getBalance summary: Get current credit balance description: Returns the calling account's remaining credit balance. Requires the `validate:read` scope. responses: '200': description: Current balance. content: application/json: schema: $ref: '#/components/schemas/Balance' examples: balance: value: credits: 4820 '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' /v1/results: get: tags: [Account] operationId: listResults summary: List recent validation history description: | Returns the calling account's recent validation results (both single probes and batch results), newest first, with cursor pagination. Requires the `validate:read` scope. Rate limit: 120 requests/minute. Pass the previous page's `next_cursor` as `cursor` to fetch the next page. `next_cursor` is `null` on the last page. parameters: - name: limit in: query description: Maximum rows to return. required: false schema: type: integer minimum: 1 maximum: 200 default: 50 - name: cursor in: query description: ISO 8601 timestamp (`probed_at` of the last row from the previous page). Rows strictly older than this are returned. required: false schema: type: string format: date-time - name: kind in: query description: Filter by result origin. required: false schema: type: string enum: [single, batch, all] default: all responses: '200': description: A page of results. content: application/json: schema: $ref: '#/components/schemas/ResultsPage' examples: page: value: results: - id: "10294" email: jane.doe@gmail.com status: valid score: 88 kind: single job_id: null latency_ms: 742 probed_at: 2026-07-30T12:00:05.000Z cached_from: null - id: "10293" email: sales@example.com status: risky score: 58 kind: batch job_id: 6f1d2c9a-6b1e-4f0a-9e2a-1b2c3d4e5f60 latency_ms: 690 probed_at: 2026-07-30T12:00:06.000Z cached_from: null next_cursor: 2026-07-30T12:00:06.000Z '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/Internal' webhooks: batchCompleted: post: summary: Batch completion callback description: | If a `webhookUrl` was supplied to `POST /v1/batch`, ZI² Verify POSTs this payload to that URL when the job finishes. requestBody: content: application/json: schema: $ref: '#/components/schemas/BatchCompletedEvent' examples: completed: value: event: batch.completed job_id: 6f1d2c9a-6b1e-4f0a-9e2a-1b2c3d4e5f60 processed: 3 successful: 1 completed_at: 2026-07-30T12:00:09.000Z responses: '2XX': description: Acknowledge receipt with any 2xx status. components: securitySchemes: bearerAuth: type: http scheme: bearer description: | API key as a Bearer token: `Authorization: Bearer zi2v_live__`. Scopes: `validate:read`, `validate:write`. parameters: IdempotencyKey: name: Idempotency-Key in: header required: false description: | Client-generated key (8–128 characters) making the write idempotent. Repeats with the same key and body replay the original response (`Idempotent-Replay: true`); repeats with a different body return `409 CONFLICT`. Retained 24 hours. schema: type: string minLength: 8 maxLength: 128 JobId: name: id in: path required: true description: Batch job UUID. schema: type: string format: uuid responses: Unauthorized: description: Missing or invalid API key. content: application/json: schema: $ref: '#/components/schemas/Error' examples: missing: value: error: code: UNAUTHORIZED message: Missing Bearer token in Authorization header invalid: value: error: code: UNAUTHORIZED message: Invalid or revoked API key Forbidden: description: API key lacks the required scope. content: application/json: schema: $ref: '#/components/schemas/Error' examples: scope: value: error: code: FORBIDDEN message: 'API key missing required scope: validate:write' details: missingScope: validate:write NotFound: description: Resource not found or not owned by the calling account. content: application/json: schema: $ref: '#/components/schemas/Error' examples: notFound: value: error: code: NOT_FOUND message: Job not found Conflict: description: Idempotency-Key reused with a different request body. content: application/json: schema: $ref: '#/components/schemas/Error' examples: conflict: value: error: code: CONFLICT message: Idempotency-Key was reused with a different request body details: field: Idempotency-Key RateLimited: description: Rate limit exceeded. headers: Retry-After: description: Seconds to wait before retrying. schema: type: integer content: application/json: schema: $ref: '#/components/schemas/Error' examples: rateLimited: value: error: code: RATE_LIMITED message: Rate limit exceeded details: retryAfter: 42 Internal: description: Unexpected server error. content: application/json: schema: $ref: '#/components/schemas/Error' examples: internal: value: error: code: INTERNAL message: Something went wrong. Please try again. schemas: ValidateRequest: type: object required: [email] additionalProperties: false properties: email: type: string format: email maxLength: 320 description: Email address to validate. example: jane.doe@gmail.com cache: type: boolean default: false description: Reuse this account's recent probe of the same address if available. BatchRequest: type: object required: [emails] additionalProperties: false properties: emails: type: array minItems: 1 maxItems: 100000 description: Email addresses to validate. Lower-cased and de-duplicated server-side. items: type: string format: email maxLength: 320 webhookUrl: type: string format: uri description: URL to POST a `batch.completed` event to when the job finishes. listName: type: string maxLength: 200 description: Optional label for the job. ValidationStatus: type: string description: Overall verdict for the address. enum: [valid, invalid, risky, unknown, disposable, spamtrap] CheckReason: type: string description: Machine-readable reason code for the verdict. enum: - ok - syntax_error - domain_not_found - no_mx_record - smtp_rejected - mailbox_not_found - catch_all - role_based - disposable_domain - timeout - connection_error JobStatus: type: string description: Lifecycle state of a batch job. enum: [queued, running, completed, failed] ResultKind: type: string enum: [single, batch] AuthResult: type: object description: | Deep email-authentication and deliverability signals for the address's domain and MX, as produced by the validation engine. Fields may be omitted or carry engine defaults when a lookup is unavailable. properties: spf: type: string description: SPF posture, e.g. `strict`, `softfail`, `pass`, or `none`. example: strict dkim: type: string description: DKIM presence, e.g. `found` or `none`. example: found dmarc: type: string description: DMARC presence, e.g. `found` or `none`. example: found dmarc_policy: type: string description: DMARC `p=` policy. example: reject zi2_certified: type: boolean example: false zi2_trust_level: type: integer example: 0 zi2_key_id: type: string example: "" mta_sts: type: boolean example: true tls_rpt: type: boolean example: false dane: type: boolean example: false dnssec: type: boolean example: true bimi: type: boolean example: false bimi_vmc: type: boolean example: false caa: type: boolean example: true fcrdns: type: boolean example: true ptr: type: string description: Reverse-DNS hostname of the MX IP. example: mail-sor-f41.google.com dmarc_subdomain_policy: type: string description: DMARC `sp=`; defaults to the apex policy when absent. example: reject dmarc_alignment_spf: type: string description: DMARC `aspf=` (`r` relaxed / `s` strict). example: r dmarc_alignment_dkim: type: string description: DMARC `adkim=` (`r` relaxed / `s` strict). example: r dmarc_rua_set: type: boolean example: true tls_version: type: string description: TLS protocol negotiated on the MX. example: TLSv1.3 tls_cipher: type: string example: TLS_AES_256_GCM_SHA384 abuse_mailbox: type: boolean example: false postmaster_mailbox: type: boolean example: false helo_matches_rdns: type: boolean example: true dnsbl_clean: type: boolean example: true dnsbl_lists_on: type: array items: type: string example: [] dnswl_listed: type: boolean example: false domain_age_days: type: integer description: WHOIS-derived domain age in days; 0 when unknown. example: 0 Checks: type: object description: | Flattened per-layer signals for the address. Auth-related keys are also surfaced individually here and repeated in full under `auth`. required: - syntax - mx - is_disposable - is_role_based - is_free_provider - is_catch_all - auth properties: reason: $ref: '#/components/schemas/CheckReason' syntax: type: boolean description: Address passed syntax validation. example: true mx: type: boolean description: An MX host was resolved for the domain. example: true domain: type: string example: gmail.com mx_host: type: string example: gmail-smtp-in.l.google.com is_disposable: type: boolean example: false is_role_based: type: boolean example: false is_free_provider: type: boolean example: true is_catch_all: type: boolean example: false spf: type: string example: strict dkim: type: string example: found dmarc: type: string example: found mta_sts: type: boolean example: true zi2_certified: type: boolean example: false auth: $ref: '#/components/schemas/AuthResult' CertifiedReceipt: type: object description: | A signed validation result. `kid` and `sig` are top-level fields (not nested): `sig` is a base64url Ed25519 signature over the canonicalized receipt (keys sorted, `sig` omitted), verifiable against the published key identified by `kid`. required: - email - status - score - checks - probed_at - latency_ms - kid - sig - credits_remaining - cached properties: email: type: string format: email example: jane.doe@gmail.com status: $ref: '#/components/schemas/ValidationStatus' score: type: integer minimum: 0 maximum: 100 example: 88 checks: $ref: '#/components/schemas/Checks' probed_at: type: string format: date-time example: 2026-07-30T12:00:05.000Z latency_ms: type: integer example: 742 kid: type: string description: Signing key identifier. example: v1 sig: type: string description: base64url Ed25519 signature over the canonicalized receipt. example: 3q2-7w9k...signature...base64url credits_remaining: type: integer description: Remaining credit balance after this validation. example: 4819 cached: type: boolean description: Whether this receipt was served from the account's recent cache. example: false BatchAccepted: type: object required: [job_id, status, credits_reserved, progress, status_url] properties: job_id: type: string format: uuid status: type: string enum: [queued] credits_reserved: type: integer example: 3 progress: type: object required: [processed, total] properties: processed: type: integer example: 0 total: type: integer example: 3 status_url: type: string example: /v1/jobs/6f1d2c9a-6b1e-4f0a-9e2a-1b2c3d4e5f60 BatchJob: type: object required: - job_id - status - progress - credits_reserved - created_at - started_at - completed_at - results_url properties: job_id: type: string format: uuid status: $ref: '#/components/schemas/JobStatus' progress: type: object required: [processed, total] properties: processed: type: integer example: 3 total: type: integer example: 3 credits_reserved: type: integer example: 3 created_at: type: string format: date-time started_at: type: [string, "null"] format: date-time completed_at: type: [string, "null"] format: date-time results_url: type: [string, "null"] description: Path to the CSV download; populated only when `status` is `completed`. example: /v1/jobs/6f1d2c9a-6b1e-4f0a-9e2a-1b2c3d4e5f60/results.csv ResultItem: type: object required: - id - email - status - score - kind - job_id - latency_ms - probed_at - cached_from properties: id: type: string description: Result identifier (numeric id serialized as string). example: "10294" email: type: string format: email status: $ref: '#/components/schemas/ValidationStatus' score: type: integer minimum: 0 maximum: 100 kind: $ref: '#/components/schemas/ResultKind' job_id: type: [string, "null"] format: uuid description: Owning batch job UUID, or null for single probes. latency_ms: type: [integer, "null"] probed_at: type: string format: date-time cached_from: type: [string, "null"] format: uuid description: Source result this row was served from, when cached. ResultsPage: type: object required: [results, next_cursor] properties: results: type: array items: $ref: '#/components/schemas/ResultItem' next_cursor: type: [string, "null"] format: date-time description: Cursor for the next page, or null on the last page. Balance: type: object required: [credits] properties: credits: type: number example: 4820 BatchCompletedEvent: type: object required: [event, job_id, processed, successful, completed_at] properties: event: type: string enum: [batch.completed] job_id: type: string format: uuid processed: type: integer example: 3 successful: type: integer example: 1 completed_at: type: string format: date-time Error: type: object required: [error] properties: error: type: object required: [code, message] properties: code: type: string enum: - BAD_REQUEST - UNAUTHORIZED - FORBIDDEN - NOT_FOUND - CONFLICT - RATE_LIMITED - INTERNAL description: | Stable error code. HTTP status mapping: BAD_REQUEST=400, UNAUTHORIZED=401, FORBIDDEN=403, NOT_FOUND=404, CONFLICT=409, RATE_LIMITED=429, INTERNAL=500. message: type: string details: type: object additionalProperties: true description: | Optional context. Examples: `currentCredits` (validate insufficient credits); `currentCredits`/`needed`/`deltaShortfall` (batch insufficient credits); `missingScope` (403); `field` (409); `retryAfter` (429); `issues` (body validation).