openapi: 3.1.0
info:
  title: 'Spaw API'
  version: 1.0.0
  summary: 'Verification API for email, phone, IP, postal address and company identifiers, with one key, one credit balance, and typed JSON answers that name their data sources.'
  description: |
    Spaw verifies email addresses, phone numbers, IP addresses, postal addresses and company identifiers over plain REST, and compares those signals against each other: JSON in and out, typed errors, and one credit balance across every endpoint. An email lookup runs RFC syntax parsing, live DNS and MX checks, SPF and DMARC records, open disposable and role lists, and a mailbox-level SMTP handshake; the other signals answer from datasets held on our own servers, and every answer names the datasets it came from. No verification ever sends mail, and nothing is ever sent to a number or an address you check.

    ## Authentication

    Every request carries a secret API key as a bearer token. Create and revoke keys on the dashboard's API keys page; a key is shown once at creation and stored hashed.

    ```http
    Authorization: Bearer sk_live_…
    Content-Type: application/json
    ```

    There are two kinds of keys. Secret `sk_live_` keys authenticate servers and unlock the full API. Publishable `pk_` keys are safe in browser page source: they can only run single lookups through the four browser endpoints — `POST /api/v1/email/public`, `POST /api/v1/phone/public` (which never runs the live carrier-network check), `POST /api/v1/ip/public` and `POST /api/v1/address/public` — and only from the domains they are locked to.

    A key only works once the account behind it has confirmed its email address. Until then every request under `/api/v1`, and every MCP tool call, answers `403 EMAIL_NOT_VERIFIED` with the standard error envelope, and nothing is charged. Open the link in the verification email, or sign in and request a new one from the verification notice, then retry the same call. The browser endpoint and the bounce webhook URLs are unaffected.

    ## Response envelope

    Successful lookups answer `{ "success": true, "data": …, "meta": … }`. The `meta` block carries `credits_used`, `credits_remaining`, `cache_hit`, and `request_id` (email lookups add `suppressed` and `fair_use_billed`, and `settling` when a callback was requested; IP lookups add `ip_lookups_prepaid`). Failures answer `{ "success": false, "error": { "code", "message", "request_id" } }` with a stable code to branch on; validation failures add `error.errors` keyed by field. Every failure answers in that shape, including the ones the framework used to answer for itself: a record that is not on the account is `404 NOT_FOUND`, a wrong HTTP verb is `405 METHOD_NOT_ALLOWED` with an `Allow` header, and a failure on this side is `500 SERVER_ERROR`. Every response also carries an `X-Request-Id` header that matches every log line the request produced. Quote it when writing to support.

    ## Billing

    You pay for answers, not attempts, and anything you paid to check once is free to check again for seven days.

    | Outcome | Credits |
    | --- | --- |
    | Fresh lookup with a usable answer (deliverable or risky verdict, a domain that accepts mail, a valid phone number, an address that stands as written, a register that answers) | 1 |
    | Five fresh IP lookups that place an address | 1 |
    | Email answer whose mailbox check was uncertain (`unverified`, including a handshake partner outage) | 0 |
    | Undeliverable email verdict within the fair-use allowance | 0 |
    | Undeliverable email verdict past the fair-use allowance (`meta.fair_use_billed` true) | 1 |
    | Invalid input or no match | 0 |
    | Repeat of a recent lookup, within 7 days | 0 |
    | Repeat asked for fresh with `refresh` or an exceeded `max_age`, when answered | 1 |
    | Rejected or failed request | 0 |
    | Monthly free grant, every account | +100 |

    An uncertain mailbox answer writes no seven-day repeat marker, so a later lookup of the address is a fresh one: the uncertain probe result is reused for up to 15 minutes (`refresh` and `max_age` do not set it aside), then the mailbox is asked again, and the lookup bills 1 credit only once the mailbox is confirmed.

    Fair use: undeliverable email verdicts that needed a mailbox check are free up to 500 in any 30 days, or the number of deliverable and risky email answers billed in those 30 days when that is more. Past the allowance each one bills 1 credit and answers `meta.fair_use_billed: true`. Verdicts settled without a mailbox check (bad syntax, no working mail server, a username the provider never allows), suppressed answers, test addresses and repeats are always free and never counted.

    IP lookups are billed in blocks of five. The answered IP lookup that opens a block reports `credits_used: 1` and leaves four prepaid on the account; the next four answered IP lookups report `credits_used: 0`. `meta.ip_lookups_prepaid` says how many are left after the call, and one `ip_lookup` row in the ledger stands for a block of five. Reserved, private and unallocated ranges, unknown addresses and repeats are free and never use a prepaid lookup. ASN and range lookups are not IP lookups and bill 1 credit each.

    Credit packs are one-time purchases from the dashboard's Billing page (from $9 for 1,000 credits); plans deposit the same credits every month for less (from $7 a month, or yearly for ten months' price) and can be cancelled at any time. Plan credits are spent first. Pack credits never expire; plan credits roll over up to three months' allowance and stay usable for 60 days after you cancel. `GET /api/v1/account` returns the live balance and the plan for pre-flight checks.

    ## Rate limits

    Every key may make 5 requests per second; a batch or bulk call counts as one request. Over the limit, the API answers `429 RATE_LIMITED` with a `Retry-After` header.

    ## Test addresses

    Six fixed addresses on the reserved `spaw.test` domain always answer the same canonical result, cost no credits, touch no DNS or mail server, and never appear in your history or suppression list. A plus-tag (`deliverable+ci@spaw.test`) works too, on every endpoint including batch, bulk, and the browser endpoint.

    | Address | Answer |
    | --- | --- |
    | deliverable@spaw.test | deliverable · mailbox confirmed, risk_score 0 |
    | unverified@spaw.test | risky · reason unverified, smtp_checked false, smtp_reason greylisted, mailbox_confidence set |
    | catch-all@spaw.test | risky · reason catch_all, mailbox_exists null |
    | disposable@spaw.test | risky · reason disposable, risk_score 90 |
    | role@spaw.test | risky · reason role |
    | undeliverable@spaw.test | undeliverable · reason mailbox_not_found, smtp_reason no_mailbox |

    ## Data sources

    Every lookup's `data.sources` array names the dataset and version behind the answer (`rfc-5322-syntax`, `live-dns`, `disposable-domains`, `role-addresses`, `free-provider-domains`, `typo-domains`, `provider-username-rules`, `mx-provider-patterns`, `rdap-registration`, the mailbox probe's provider and date, `delivery-feedback`, `suppression-list`, or `test-addresses`), so an answer can always be audited.
  termsOfService: 'https://spaw.co/terms'
  contact:
    name: 'Spaw support'
    email: support@spaw.co
    url: 'https://spaw.co'
  x-logo:
    url: 'https://spaw.co/icons/icon-512.png'
    altText: Spaw
servers:
  -
    url: 'https://spaw.co'
    description: Production
security:
  -
    bearerAuth: []
tags:
  -
    name: Email
    description: 'Single-address verification, the synchronous batch endpoint, and the browser endpoint behind publishable keys.'
  -
    name: Domain
    description: 'Domain-level signals answered once for a whole domain.'
  -
    name: Bulk
    description: 'Queued runs of up to 100,000 addresses with a signed completion webhook and a CSV download.'
  -
    name: Suppressions
    description: "The account's known-bad list, fed automatically by undeliverable verdicts and by hand."
  -
    name: Feedback
    description: 'Delivery outcomes you observed, which keep the suppression list current and turn verdicts into measured accuracy.'
  -
    name: Monitors
    description: 'Saved lists re-verified every week or month, with decay alerts when a deliverable address stops being one.'
  -
    name: History
    description: "The account's own verification history across every channel, for the retention window."
  -
    name: Account
    description: 'The account behind the key.'
  -
    name: Phone
    description: "Phone number validation and enrichment: formats, country facts, line type and carrier with their sources, the regulator's block status, flags and a recomputable risk score."
  -
    name: IP
    description: "IP geolocation, network and registry facts, compliance facts and recomputable risk flags, plus the account's own allow and deny lists, and answers about a whole autonomous system or prefix."
  -
    name: Address
    description: "Postal address validation: the country's own format, the operators' rules for boxes, the national address registers and postcode directories, the open company registers, and a recomputable risk score."
  -
    name: Entity
    description: 'Business identifier verification: a Legal Entity Identifier or a company number read against the registers this server syncs, and where both hold one entity, what they agree and disagree about.'
  -
    name: Consistency
    description: "One call across several signals: each product's own answer, plus named agreements and disagreements between them with the fields that decided each one."
  -
    name: Webhooks
    description: 'Test deliveries of the payloads this API posts, so a signature check can be written against a real signed request rather than a paid run.'
paths:
  /api/v1/email:
    post:
      operationId: verify-email
      tags:
        - Email
      summary: 'Verify an email address'
      description: |
        Runs the full pipeline on one address and answers with a verdict, a machine-readable reason, an auditable 0–100 risk score with the signals behind it, and 46 named fields. The checks run in order: address extraction (display names, `mailto:` prefixes, spreadsheet quotes and invisible characters are stripped), RFC 5322 syntax, the shape of the username (naming pattern, name tokens, machine-made or placeholder look), live MX resolution including the null-MX and implicit-MX rules, the relay, disposable, role, free-provider and typo-squat lists, provider-specific username rules, the MX hosts against the threat blocklists and the parking nameservers, SPF and DMARC records, the domain's registration and expiry from RDAP, and finally a mailbox-level SMTP handshake that ends before any message is transmitted, read against the address's own not-found history and how many other accounts checked it today.

        The mailbox probe runs only when the domain accepts mail and no free signal already settled the answer, so a disposable domain or a typo-squat never reaches the mail server. Confirmed mailbox answers are shared across customers for up to a week, which is why `smtp_checked_at` can predate the lookup. When mail servers refuse to say whether an inbox exists (greylisting, timeouts), the answer degrades honestly to a `risky` verdict with reason `unverified`, `smtp_checked: false` and `mailbox_exists: null`, plus a recomputable `mailbox_confidence` estimate; an unverified answer whose `smtp_reason` is `provider_unavailable` is free.

        A single lookup never consults your suppression list, and its verdict clears only an entry a verdict added: an import, or an entry your own bounce and complaint reports added, stays until you remove it (a reported bounce also leaves when a delivery to that address is reported; a complaint only when you remove it). The batch, bulk and monitor paths do honor it.

        ### Asking for a fresh answer

        Repeats inside the seven-day window are free and served from the earlier answer. Pass `refresh: true` to discard that answer and the shared mailbox answer, run a real handshake again, and bill the lookup as fresh when it is answered; or pass `max_age` in seconds to do so only when the earlier answer is older than that. `meta.refreshed` says whether an earlier answer was discarded for the call. An uncertain (`unverified`) mailbox answer under 15 minutes old is the one exception: it is free, so neither control sets it aside, and the call answers it again. `timeout` caps how long the mailbox handshake may wait for this call; a handshake that runs out of time answers `unverified` with `smtp_reason: timeout` instead of holding the request.

        ### Settling an unverified answer later

        Pass `callback_url` and `callback_secret` and an unverified answer is re-checked after 5 and 20 minutes. The settled verdict is POSTed to the callback as `{ "event": "email.settled", "request_id", "settled", "attempt", "data", "meta" }`, signed with `callback_secret` the way every webhook here is: `X-Spaw-Signature-V2` carries `t={unix seconds},v2={lowercase hex}`, the HMAC-SHA256 of `v2:{t}:{raw body}`, with the older `X-Spaw-Signature` over the body alone beside it until 1 March 2027. The response to this request then carries `meta.settling: true`. An unverified answer is free and leaves nothing to repeat, so each re-check is billed like a fresh lookup: 1 credit when it confirms the mailbox, nothing while it still cannot say (`settled` is false when the last re-check still could not say), and an undeliverable answer counts against your email fair use. A re-check your balance cannot pay for is not run: the last callback then has `settled` false, `data` null, `meta.credits_used` 0 and `meta.stopped_reason: "insufficient_credits"`.
      x-spaw-auth: bearer
      x-spaw-billing: '1 credit for a fresh deliverable or risky verdict whose mailbox check was definite; an unverified answer (the mailbox check could not say, including a provider outage) is free, and so are invalid input, 7-day repeats and test addresses. An undeliverable verdict is free within your email fair use and bills 1 credit past it, with `meta.fair_use_billed` true.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - INSUFFICIENT_CREDITS
        - RATE_LIMITED
        - SANDBOX_VALUE_NOT_ALLOWED
        - SANDBOX_UNAVAILABLE
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - email
              properties:
                email:
                  type: string
                  maxLength: 254
                  description: 'The address to verify. Wrappers people paste (a display name, a `mailto:` prefix, quotes, trailing punctuation) are stripped before checking.'
                callback_url:
                  type:
                    - string
                    - 'null'
                  format: uri
                  maxLength: 2048
                  description: 'An https URL. When the answer comes back unverified (greylisted, timed out, provider unavailable), the address is re-checked after 5 and 20 minutes and the settled verdict is POSTed here.'
                callback_secret:
                  type:
                    - string
                    - 'null'
                  minLength: 16
                  maxLength: 128
                  description: 'Required with `callback_url`. Signs the callback body exactly as the bulk webhook is signed — `X-Spaw-Signature-V2` carries `t={unix seconds},v2={lowercase hex}`, the HMAC-SHA256 of `v2:{t}:{raw body}`.'
                refresh:
                  type:
                    - boolean
                    - 'null'
                  description: 'Discard the answer from the seven-day repeat window and the shared mailbox answer, run the handshake again, and bill as a fresh lookup when answered.'
                max_age:
                  type:
                    - integer
                    - 'null'
                  minimum: 60
                  maximum: 604800
                  description: 'Seconds. Refresh only when the earlier answer is older than this; a younger answer is served free as usual.'
                timeout:
                  type:
                    - integer
                    - 'null'
                  minimum: 2
                  maximum: 60
                  description: 'Seconds the mailbox handshake may wait for this call. When it runs out the answer is `unverified` with `smtp_reason` `timeout`.'
                fields:
                  type:
                    - string
                    - 'null'
                  maxLength: 1000
                  description: "A comma-separated list of the answer's own field names to return, in the answer's order. Unknown names are ignored, a blank list means everything, and `sources` comes back only when named. The meta block is never narrowed."
                  example: 'email,deliverable,risk_score,ok_to_send'
            example:
              email: mia@acme.com
      responses:
        200:
          description: 'The verdict and every field, whether or not the address is deliverable.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/EmailResult'
                  meta:
                    $ref: '#/components/schemas/EmailMeta'
              example:
                success: true
                data:
                  email: mia@acme.com
                  normalized_email: mia@acme.com
                  is_alias: false
                  is_gibberish: false
                  looks_generated: false
                  local_part_pattern: first
                  first_name: mia
                  last_name: null
                  deliverable: deliverable
                  reason: null
                  risk_score: 0
                  risk_level: low
                  risk_signals: []
                  syntax_valid: true
                  domain: acme.com
                  domain_category: other
                  mx_found: true
                  mx_implicit: false
                  mx_provider: google
                  mx_provider_type: mailbox_provider
                  mx_blocklisted: false
                  mx_network_type: hosting
                  has_spf: true
                  dmarc_policy: reject
                  domain_registered_at: '1998-03-12'
                  domain_age_days: 10402
                  domain_expires_at: '2027-03-12'
                  parked: false
                  disposable: false
                  is_relay: false
                  relay_service: null
                  role: false
                  role_type: null
                  free_provider: false
                  smtp_checked: true
                  smtp_checked_at: '2026-09-03T10:12:44+00:00'
                  mailbox_exists: true
                  previously_invalid: false
                  previously_invalid_at: null
                  catch_all: false
                  smtp_reason: null
                  mailbox_confidence: null
                  widely_seen: false
                  did_you_mean: null
                  ok_to_send: true
                  blocked_by: null
                  sources:
                    -
                      dataset: rfc-5322-syntax
                      version: '2026-09-03'
                    -
                      dataset: live-dns
                      version: '2026-09-03'
                    -
                      dataset: disposable-domains
                      version: '2026-09-01'
                    -
                      dataset: mx-provider-patterns
                      version: '2026-08-30'
                    -
                      dataset: mailbox-smtp
                      version: '2026-09-03'
                meta:
                  credits_used: 1
                  credits_remaining: 9
                  cache_hit: false
                  refreshed: false
                  suppressed: false
                  fair_use_billed: false
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/email \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "email": "mia@acme.com"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "email": "mia@acme.com"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/email',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'email': 'mia@acme.com'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'email' => 'mia@acme.com'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.verify_email('mia@acme.com')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.verifyEmail('mia@acme.com');
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->verifyEmail('mia@acme.com');
  /api/v1/email/batch:
    post:
      operationId: verify-email-batch
      tags:
        - Email
      summary: 'Verify up to 50 addresses in one call'
      description: |
        Verifies a list synchronously under the exact single-lookup billing rules. Every address is prepared first, then all the mailbox probes that are still needed are sent to the verification provider together, so a full batch takes about as long as a handful of single lookups. `data.results[]` mirrors the single-address response per item, in input order, and each item's `meta` carries `credits_used`, `cache_hit`, `suppressed`, `refreshed` and `fair_use_billed`.

        Addresses on your suppression list are served from the stored verdict at no cost and marked `suppressed: true`. Repeats inside the list are verified once. The list is walked in order and stops where the balance would run out: running out of credits mid-batch returns the paid partial results with `meta.stopped_reason: "insufficient_credits"` and `processed` lower than `requested`, never discarding billed work. Only a batch whose first lookup is refused answers the typed `402`. A key with a daily credit cap stops the list the same way once the cap is spent: the addresses already paid for come back with `meta.stopped_reason: "key_spend_cap"`, and a call that starts with the cap already spent answers `429 KEY_SPEND_CAP_REACHED` and runs nothing.

        A batch counts as one request against the rate limit. `refresh`, `max_age` and `timeout` work exactly as on the single-address endpoint and apply to every address in the list; each item's `meta.refreshed` says whether its earlier answer was discarded.

        Every item carries `index`, its zero-based position in the list you sent, and `input`, the address exactly as you sent it. A batch stopped early by `stopped_reason` answers only a prefix of the list, so those two are what line an answer up with the row it came from.
      x-spaw-auth: bearer
      x-spaw-billing: 'Each address bills like a single lookup — 1 credit for a fresh deliverable or risky verdict whose mailbox check was definite, an unverified answer free, an undeliverable one free within your email fair use and 1 credit past it (`fair_use_billed` on the item), everything else free — and the call stops cleanly where the balance ends.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - INSUFFICIENT_CREDITS
        - KEY_SPEND_CAP_REACHED
        - RATE_LIMITED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - emails
              properties:
                emails:
                  type: array
                  minItems: 1
                  maxItems: 50
                  items:
                    type: string
                    maxLength: 254
                  description: '1 to 50 addresses; each item at most 254 characters.'
                refresh:
                  type:
                    - boolean
                    - 'null'
                  description: 'Apply to every address — discard earlier answers, probe again, bill as fresh when answered. Repeats inside the list are still verified once.'
                max_age:
                  type:
                    - integer
                    - 'null'
                  minimum: 60
                  maximum: 604800
                  description: 'Seconds. Refresh only the addresses whose earlier answer is older than this.'
                timeout:
                  type:
                    - integer
                    - 'null'
                  minimum: 2
                  maximum: 60
                  description: 'Seconds the mailbox handshake may wait, for every address in the call.'
            example:
              emails:
                - mia@acme.com
                - info@example.org
                - deliverable@spaw.test
      responses:
        200:
          description: 'One result per processed address, plus the batch totals.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchResponse'
              example:
                success: true
                data:
                  results:
                    -
                      index: 0
                      input: mia@acme.com
                      data:
                        email: mia@acme.com
                        deliverable: deliverable
                        reason: null
                        risk_score: 0
                        risk_level: low
                      meta:
                        credits_used: 1
                        cache_hit: false
                        suppressed: false
                        refreshed: false
                        fair_use_billed: false
                    -
                      index: 1
                      input: info@example.org
                      data:
                        email: info@example.org
                        deliverable: risky
                        reason: role
                        risk_score: 30
                        risk_level: medium
                      meta:
                        credits_used: 1
                        cache_hit: false
                        suppressed: false
                        refreshed: false
                        fair_use_billed: false
                    -
                      index: 2
                      input: deliverable@spaw.test
                      data:
                        email: deliverable@spaw.test
                        deliverable: deliverable
                        reason: null
                        risk_score: 0
                        risk_level: low
                      meta:
                        credits_used: 0
                        cache_hit: false
                        suppressed: false
                        refreshed: false
                        fair_use_billed: false
                meta:
                  requested: 3
                  processed: 3
                  credits_used: 2
                  credits_remaining: 7
                  stopped_reason: null
                  request_id: req_01m1kgdrtqdvwnks99vfgx2rcw
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/email/batch \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "emails": [
                "mia@acme.com",
                "info@example.org",
                "deliverable@spaw.test"
              ]
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/batch', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "emails": [
                  "mia@acme.com",
                  "info@example.org",
                  "deliverable@spaw.test"
                ]
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/email/batch',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'emails': [
                        'mia@acme.com',
                        'info@example.org',
                        'deliverable@spaw.test'
                    ]
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/batch');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'emails' => [
                        'mia@acme.com',
                        'info@example.org',
                        'deliverable@spaw.test'
                    ]
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.verify_emails([
                'mia@acme.com',
                'info@example.org',
                'deliverable@spaw.test'
            ])
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.verifyEmails([
                'mia@acme.com',
                'info@example.org',
                'deliverable@spaw.test'
            ]);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->verifyEmails([
                'mia@acme.com',
                'info@example.org',
                'deliverable@spaw.test'
            ]);
  /api/v1/email/public:
    post:
      operationId: verify-email-public
      tags:
        - Email
      summary: 'Verify an address from the browser with a publishable key'
      description: |
        The endpoint behind the form widget (`https://spaw.co/spaw-form.js`). It is authenticated by a publishable `pk_` key in the body plus the browser's `Origin` header, which must match one of the domains the key is locked to. A missing `Origin` is rejected on purpose: servers use a secret key and `POST /api/v1/email` instead.

        The lookup bills the key's owner under the normal rules and answers `{ "success", "data" }` with **no meta block**, so page visitors never see the owner's balance. Because the key sits in page source, give it a daily credit cap in the dashboard: once the cap is spent the endpoint answers `429 KEY_SPEND_CAP_REACHED` until the next day. Free answers (undeliverable, cache hits, test addresses) never count toward the cap.

        For forms open to the public, store a Cloudflare Turnstile site key and secret on the publishable key. The endpoint then requires a confirmed `turnstile_token` with every lookup and answers `403 TURNSTILE_FAILED` without one, before any credit is spent. Tokens are single-use. The form widget obtains a fresh token per lookup when the script tag carries `data-turnstile-site-key`.

        Throttled at 20 requests per minute per IP.
      security: []
      x-spaw-auth: publishable-key
      x-spaw-billing: "Bills the key owner like a single lookup; capped by the key's daily credit cap when one is set."
      x-spaw-errors:
        - INVALID_PUBLISHABLE_KEY
        - ORIGIN_NOT_ALLOWED
        - TURNSTILE_FAILED
        - KEY_SPEND_CAP_REACHED
        - VALIDATION_FAILED
        - INSUFFICIENT_CREDITS
        - RATE_LIMITED
      parameters:
        -
          name: Origin
          in: header
          required: true
          schema:
            type: string
          example: 'https://www.example.com'
          description: "Sent by browsers automatically. Its host must be on the key's allowed-domain list."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - key
                - email
              properties:
                key:
                  type: string
                  maxLength: 64
                  description: 'A publishable key, which starts with `pk_`.'
                email:
                  type: string
                  maxLength: 254
                  description: 'The address to verify.'
                fields:
                  type:
                    - string
                    - 'null'
                  maxLength: 1000
                  description: "A comma-separated list of the answer's own field names to return, in the answer's order. Unknown names are ignored, a blank list means everything, and `sources` comes back only when named. The meta block is never narrowed."
                  example: 'email,deliverable,risk_score,ok_to_send'
                turnstile_token:
                  type:
                    - string
                    - 'null'
                  maxLength: 2048
                  description: 'Required when the key carries a Turnstile pair. A single-use token from the Turnstile widget on the page.'
            example:
              key: pk_live_…
              email: mia@acme.com
      responses:
        200:
          description: 'The same fields as `POST /api/v1/email`, without a meta block.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/EmailResult'
              example:
                success: true
                data:
                  email: mia@acme.com
                  deliverable: deliverable
                  reason: null
                  risk_score: 0
                  risk_level: low
                  did_you_mean: null
                  ok_to_send: true
                  blocked_by: null
        401:
          description: 'The publishable key does not exist or was revoked.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: INVALID_PUBLISHABLE_KEY
                  message: 'That publishable key does not exist or was revoked.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        402:
          $ref: '#/components/responses/InsufficientCredits'
        403:
          description: "The page's origin is not on the key's allowed-domain list (`ORIGIN_NOT_ALLOWED`), or the key requires a Turnstile token that was missing or not confirmed (`TURNSTILE_FAILED`)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: ORIGIN_NOT_ALLOWED
                  message: 'This publishable key cannot be used from this origin.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          description: "The key has spent its owner's daily credit cap (`KEY_SPEND_CAP_REACHED`), or the IP exceeded 20 requests per minute (`RATE_LIMITED`)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: KEY_SPEND_CAP_REACHED
                  message: 'This publishable key has reached its daily credit cap. The counter resets each day.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/email/public \
              -H "Origin: https://www.example.com" \
              -H "Content-Type: application/json" \
              -d '{
              "key": "pk_live_…",
              "email": "mia@acme.com"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/public', {
              method: 'POST',
              headers: {
                'Origin': 'https://www.example.com',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "key": "pk_live_…",
                "email": "mia@acme.com"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/email/public',
                headers={'Origin': 'https://www.example.com'},
                json={
                    'key': 'pk_live_…',
                    'email': 'mia@acme.com'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/public');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Origin: https://www.example.com', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'key' => 'pk_live_…',
                    'email' => 'mia@acme.com'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
  '/api/v1/email/domain/{domain}':
    get:
      operationId: domain-intelligence
      tags:
        - Domain
      summary: "Inspect a domain's mail setup"
      description: |
        The domain-level signals of an email lookup, answered once for a whole domain: whether it can receive mail (MX records, or the RFC 5321 implicit fallback), who runs its mail, whether it publishes SPF and which DMARC policy, when it was registered, whether it is on the disposable or free-provider lists, and whether any address there has answered catch-all within the last week.

        Invalid input is an answer, not a validation error: a string that is not a hostname answers `200` with `valid: false` and every other field null. A domain with no usable mail server is free. The per-account 7-day repeat cache applies.
      x-spaw-auth: bearer
      x-spaw-billing: '1 credit only when the domain accepts mail (`mx_found` true) and the lookup is fresh; invalid or dead domains and 7-day repeats are free.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - INSUFFICIENT_CREDITS
        - RATE_LIMITED
      parameters:
        -
          name: domain
          in: path
          required: true
          schema:
            type: string
            maxLength: 254
          example: acme.com
          description: 'The domain to inspect. Lowercased and converted to punycode before checking.'
      responses:
        200:
          description: "The domain's signals; `valid: false` for input that is not a hostname."
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/DomainIntelligence'
                  meta:
                    $ref: '#/components/schemas/Meta'
              example:
                success: true
                data:
                  domain: acme.com
                  valid: true
                  domain_category: other
                  mx_found: true
                  mx_implicit: false
                  catch_all: null
                  mx_provider: google
                  mx_provider_type: mailbox_provider
                  mx_blocklisted: false
                  mx_network_type: hosting
                  has_spf: true
                  spf_all: '-all'
                  spf_lookup_count: 4
                  dmarc_policy: reject
                  dmarc_pct: 100
                  dmarc_rua: true
                  dkim_selectors:
                    - google
                  mta_sts: true
                  tls_rpt: true
                  bimi: false
                  domain_registered_at: '1998-03-12'
                  domain_age_days: 10402
                  domain_expires_at: '2027-03-12'
                  domain_updated_at: '2026-02-14'
                  registrar: 'Example Registrar, Inc.'
                  registrant_redacted: true
                  parked: false
                  disposable: false
                  is_relay: false
                  relay_service: null
                  free_provider: false
                  sources:
                    -
                      dataset: live-dns
                      version: '2026-09-03'
                    -
                      dataset: disposable-domains
                      version: '2026-09-01'
                    -
                      dataset: free-provider-domains
                      version: '2026-09-01'
                    -
                      dataset: mx-provider-patterns
                      version: '2026-08-30'
                    -
                      dataset: rdap-registration
                      version: '2026-09-03'
                    -
                      dataset: forwarding-services
                      version: '2026-10-08'
                    -
                      dataset: domain-categories
                      version: '2026-09-12'
                    -
                      dataset: parking-nameservers
                      version: '2026-09-12'
                    -
                      dataset: threat-blocklists
                      version: '2026-09-12'
                    -
                      dataset: mail-policies
                      version: '2026-09-12'
                meta:
                  credits_used: 1
                  credits_remaining: 8
                  cache_hit: false
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/email/domain/acme.com \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/domain/acme.com', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/email/domain/acme.com',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/domain/acme.com');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.domain('acme.com')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.domain('acme.com');
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->domain('acme.com');
  /api/v1/email/bulk:
    get:
      operationId: list-bulk-jobs
      tags:
        - Bulk
      summary: "List the account's bulk verification runs"
      description: |
        Every bulk run this account has started, newest first, 100 per page. A job id is handed out once, at creation, and everything about a run hangs off it — its counts, its results, its cancel and resume calls — so this is how a caller that lost one finds the run again, and how a dashboard lists what is still in flight.

        `status`, `since` and `until` are optional and combine; a bare date in `until` means the whole of that day, and an unknown status or an unparseable date is a `422` rather than an empty page. The rows are the same job objects `GET /api/v1/email/bulk/{jobId}` answers. The webhook secret is never among them: it is returned once, at creation.

        Runs are kept as long as the lookup history, and `meta.retention_days` says how long that is — a run older than that is deleted with its files and cannot be listed.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      parameters:
        -
          name: status
          in: query
          required: false
          schema:
            type: string
            enum:
              - queued
              - processing
              - completed
              - failed
              - cancelled
          example: completed
          description: 'Only runs in this status.'
        -
          name: since
          in: query
          required: false
          schema:
            type: string
          example: '2026-09-01'
          description: 'Only runs created at or after this date or timestamp.'
        -
          name: until
          in: query
          required: false
          schema:
            type: string
          example: '2026-09-08'
          description: 'Only runs created at or before this. A bare date means the whole of that day.'
        -
          name: page
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            default: 1
          example: 1
          description: 'The page to return.'
      responses:
        200:
          description: 'One page of runs, newest first.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      jobs:
                        type: array
                        items:
                          $ref: '#/components/schemas/BulkJob'
                  meta:
                    type: object
                    properties:
                      page:
                        type: integer
                      per_page:
                        type: integer
                      total:
                        type: integer
                      retention_days:
                        type: integer
                        description: 'How long a run and its files are kept before they are deleted.'
              example:
                success: true
                data:
                  jobs:
                    -
                      id: 512
                      status: completed
                      total: 2000
                      duplicate_count: 12
                      processed: 2000
                      deliverable: 1380
                      risky: 310
                      undeliverable: 310
                      credits_used: 1690
                      stopped_reason: null
                      cancel_requested: false
                      webhook_status: delivered
                      webhook_detail: 'HTTP 200'
                      created_at: '2026-09-03T10:12:44+00:00'
                      finished_at: '2026-09-03T10:19:02+00:00'
                meta:
                  page: 1
                  per_page: 100
                  total: 1
                  retention_days: 30
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/email/bulk \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/bulk', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/email/bulk',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/bulk');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.list_bulk_jobs()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.listBulkJobs();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->listBulkJobs();
    post:
      operationId: create-bulk-job
      tags:
        - Bulk
      summary: 'Queue a bulk verification run'
      description: |
        Queues up to 100,000 addresses and processes them in the background in chunks, with every chunk's mailbox probes sent concurrently. Creation answers `202` with the job's id and status. Poll `GET /api/v1/email/bulk/{jobId}` for progress, then download the result CSV from `GET /api/v1/email/bulk/{jobId}/results`. Jobs and their files are kept for 30 days.

        Rows repeating an address already in the list are verified once and counted in `duplicate_count`. Addresses at a domain already known to be catch-all, addresses on your suppression list, and addresses your account reported delivered recently answer without a probe. A run that exhausts the balance stops with `stopped_reason: "insufficient_credits"` and keeps everything verified so far.

        ### Completion webhook

        With a `webhook_url`, the finish (`completed`, `failed`, or `cancelled`) is POSTed there as `{ "event": "bulk_email_job.finished", "job": { "id", "status", "total", "processed", "deliverable", "risky", "undeliverable", "credits_used", "stopped_reason", "finished_at" } }`. The `X-Spaw-Signature-V2` header carries two comma-separated elements, `t={unix seconds},v2={lowercase hex}`: the hex is the HMAC-SHA256 of the string `v2:{t}:{raw body}`, keyed with `webhook_secret`, which this response returns once — and again only on an idempotent replay of the same request. Check the timestamp as well as the digest and refuse anything more than five minutes from your own clock: a signature over the body alone never goes stale, so a captured delivery could be posted back at you a year later and still verify. Connection errors and 5xx answers are retried twice with a short backoff, three attempts in all.

        ```php
        $elements = [];

        foreach (explode(',', (string) $request->header('X-Spaw-Signature-V2')) as $element) {
            $pair = explode('=', trim($element), 2);

            if (count($pair) === 2) {
                $elements[$pair[0]] = $pair[1];
            }
        }

        $timestamp = $elements['t'] ?? '';

        abort_unless(ctype_digit($timestamp) && abs(time() - (int) $timestamp) <= 300, 401);

        $expected = hash_hmac('sha256', "v2:{$timestamp}:".$request->getContent(), $secret);

        abort_unless(hash_equals($expected, $elements['v2'] ?? ''), 401);
        ```

        The original `X-Spaw-Signature` — the same HMAC over the raw body alone, with no timestamp — is still sent beside it until 1 March 2027, so a receiver written against it keeps working while you move over. After that only the timestamped header is sent.

        ### Retrying safely

        Send an `Idempotency-Key` header (1 to 128 printable characters, unique per request — a UUID stored with whatever triggered the run) and creation becomes safe to retry after a timeout or a dropped connection. A repeat with the same key and the same addresses and webhook URL answers the job the first attempt created, as `200` instead of `202`, with the same body including `webhook_secret` and the header `Idempotent-Replayed: true`; nothing is queued twice. The same key with a different request answers `409 IDEMPOTENCY_KEY_REUSED`. Keys are scoped to your account and live as long as the job (30 days).

        ### Reading a list before you start it

        Send `preview: true` with the same body — or `csv` with a raw CSV instead of the item list — and the call answers `200` with `data.draft` instead of queueing anything: what the list was read as, which column filled which field and whether a heading named it or we guessed, the first rows as parsed, the repeats, the balance, and `credits_upper_bound`. Nothing is charged.

        `credits_upper_bound` is a CEILING, not a quote. Rows come back free for reasons no preview can know in advance — a repeat inside the seven-day window, an entry on your suppression list, a verdict that is not charged for, a provider outage — so a run usually costs less than the ceiling and never more. It is priced against the mapping in play, so correcting a column at start re-prices the run rather than holding you to the figure the guess produced — ask for the draft again after a correction if you want the new number. What it actually cost is `credits_used` on the finished job.

        Start it with `{"draft_id": <id>}`, adding `mapping` to correct a column we read wrongly. Starting consumes the draft; one left unstarted is deleted, with the rows it holds, after `retention_hours`.
      x-spaw-auth: bearer
      x-spaw-billing: '`preview: true` is free and queues nothing. A started run bills each row like a single lookup as it is processed; repeats inside the run and 7-day repeats are free, so a run costs at most the `credits_upper_bound` the preview answered and usually less.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - IDEMPOTENCY_KEY_REUSED
        - RATE_LIMITED
      parameters:
        -
          name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            minLength: 1
            maxLength: 128
          example: 3f7c1e2a-9b4d-4c1e-8f1a-2d6b1c9e7a10
          description: 'Optional. Makes the creation safe to retry; a repeat with the same key replays the job it created instead of queueing another.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                emails:
                  type: array
                  minItems: 1
                  maxItems: 100000
                  items:
                    type: string
                    maxLength: 254
                  description: '1 to 100,000 addresses; each item at most 254 characters. Required unless the body carries `csv` with `preview: true`, or `draft_id`.'
                preview:
                  type: boolean
                  default: false
                  description: 'Read the list and answer `data.draft` — what we made of it, the repeats, the ceiling on the cost and the balance — with nothing queued and nothing billed. Start it afterwards with `draft_id`.'
                csv:
                  type:
                    - string
                    - 'null'
                  maxLength: 5000000
                  description: "A raw CSV to read instead of the item list, with the column mapping answered back. Preview only, on purpose: a CSV's columns have to be worked out, and working them out and billing for the answer in one request is what the preview exists to stop."
                draft_id:
                  type:
                    - integer
                    - 'null'
                  description: 'Start the run a preview answered with. The rows and the options come from the draft, so nothing else in the body applies except `mapping`. A draft can be started once; an `Idempotency-Key` still makes the call safe to retry.'
                mapping:
                  type:
                    - object
                    - 'null'
                  additionalProperties:
                    type: string
                  description: 'Corrects the draft''s column mapping, as column index to field — {"1": "address_line1", "2": "postal_code"}. It REPLACES the detected mapping rather than merging into it, so a column can be taken off a field as well as put on one. Goes with `draft_id`, and only for a draft read from a `csv`.'
                webhook_url:
                  type:
                    - string
                    - 'null'
                  format: uri
                  maxLength: 2048
                  description: 'An https URL to POST when the job finishes. The response includes the signing secret — shown at creation and on an idempotent replay of the same request, never otherwise.'
            example:
              emails:
                - mia@acme.com
                - info@example.org
              webhook_url: 'https://www.example.com/hooks/spaw'
      responses:
        202:
          description: 'The job was queued.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        allOf:
                          -
                            $ref: '#/components/schemas/BulkJob'
                          -
                            type: object
                            properties:
                              webhook_secret:
                                type:
                                  - string
                                  - 'null'
                                description: 'Signs the completion webhook. Returned only here.'
              example:
                success: true
                data:
                  job:
                    id: 512
                    status: queued
                    total: 2
                    duplicate_count: 0
                    processed: 0
                    deliverable: 0
                    risky: 0
                    undeliverable: 0
                    credits_used: 0
                    stopped_reason: null
                    cancel_requested: false
                    webhook_status: null
                    created_at: '2026-09-03T10:12:44+00:00'
                    finished_at: null
                    webhook_secret: '8fJ2…40 characters…Qk1'
        200:
          description: 'Either a preview — `preview: true` answers `data.draft` and queues nothing — or a retry with an `Idempotency-Key` already used for this exact request, which answers `data.job` with `Idempotent-Replayed: true`.'
          headers:
            Idempotent-Replayed:
              schema:
                type: string
                enum:
                  - 'true'
              description: 'Present only on a replayed creation.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      draft:
                        $ref: '#/components/schemas/BulkDraft'
                      job:
                        allOf:
                          -
                            $ref: '#/components/schemas/BulkJob'
                          -
                            type: object
                            properties:
                              webhook_secret:
                                type:
                                  - string
                                  - 'null'
                                description: 'The same secret the first attempt returned.'
              example:
                success: true
                data:
                  job:
                    id: 512
                    status: processing
                    total: 2
                    duplicate_count: 0
                    processed: 1
                    deliverable: 1
                    risky: 0
                    undeliverable: 0
                    credits_used: 1
                    stopped_reason: null
                    cancel_requested: false
                    webhook_status: null
                    created_at: '2026-09-03T10:12:44+00:00'
                    finished_at: null
                    webhook_secret: '8fJ2…40 characters…Qk1'
        401:
          $ref: '#/components/responses/Unauthenticated'
        409:
          description: 'The `Idempotency-Key` was already used for a different list or webhook URL.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: IDEMPOTENCY_KEY_REUSED
                  message: 'This Idempotency-Key was already used for a different request.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/email/bulk \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "emails": [
                "mia@acme.com",
                "info@example.org"
              ],
              "webhook_url": "https://www.example.com/hooks/spaw"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/bulk', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "emails": [
                  "mia@acme.com",
                  "info@example.org"
                ],
                "webhook_url": "https://www.example.com/hooks/spaw"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/email/bulk',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'emails': [
                        'mia@acme.com',
                        'info@example.org'
                    ],
                    'webhook_url': 'https://www.example.com/hooks/spaw'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/bulk');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'emails' => [
                        'mia@acme.com',
                        'info@example.org'
                    ],
                    'webhook_url' => 'https://www.example.com/hooks/spaw'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.create_bulk_job([
                'mia@acme.com',
                'info@example.org'
            ], webhook_url='https://www.example.com/hooks/spaw')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.createBulkJob([
                'mia@acme.com',
                'info@example.org'
            ], {
                webhookUrl: 'https://www.example.com/hooks/spaw'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->createBulkJob([
                'mia@acme.com',
                'info@example.org'
            ], webhookUrl: 'https://www.example.com/hooks/spaw');
  '/api/v1/email/bulk/{jobId}':
    get:
      operationId: get-bulk-job
      tags:
        - Bulk
      summary: 'Poll a bulk job'
      description: |
        The job's current status and running counts. `status` moves from `queued` to `processing` and ends in `completed`, `failed`, or `cancelled`; `processed` and the verdict counts advance as chunks finish. A job that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
          example: 512
          description: 'The id returned at creation.'
      responses:
        200:
          description: 'The job.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        $ref: '#/components/schemas/BulkJob'
              example:
                success: true
                data:
                  job:
                    id: 512
                    status: processing
                    total: 2000
                    duplicate_count: 12
                    processed: 900
                    deliverable: 610
                    risky: 140
                    undeliverable: 150
                    credits_used: 750
                    stopped_reason: null
                    cancel_requested: false
                    webhook_status: null
                    created_at: '2026-09-03T10:12:44+00:00'
                    finished_at: null
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/email/bulk/512 \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/bulk/512', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/email/bulk/512',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/bulk/512');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.get_bulk_job(512)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.getBulkJob(512);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->getBulkJob(512);
  '/api/v1/email/bulk/{jobId}/cancel':
    post:
      operationId: cancel-bulk-job
      tags:
        - Bulk
      summary: 'Cancel a bulk job'
      description: |
        Asks a queued or running job to stop. A queued job is cancelled on the spot; a running one stops at its next checkpoint and keeps the rows already written downloadable through the results endpoint. Rows already verified stay billed. The completion webhook fires with status `cancelled` just as it does for `completed` and `failed`. A job that already reached a terminal status answers `409 JOB_ALREADY_FINISHED`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - JOB_ALREADY_FINISHED
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
          example: 512
          description: 'The id returned at creation.'
      responses:
        200:
          description: 'The job, with `cancel_requested` set.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        $ref: '#/components/schemas/BulkJob'
              example:
                success: true
                data:
                  job:
                    id: 512
                    status: processing
                    total: 2000
                    duplicate_count: 12
                    processed: 900
                    deliverable: 610
                    risky: 140
                    undeliverable: 150
                    credits_used: 750
                    stopped_reason: null
                    cancel_requested: true
                    webhook_status: null
                    created_at: '2026-09-03T10:12:44+00:00'
                    finished_at: null
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        409:
          description: 'The job already finished.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: JOB_ALREADY_FINISHED
                  message: 'The job has already finished and cannot be cancelled.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/email/bulk/512/cancel \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/bulk/512/cancel', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/email/bulk/512/cancel',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/bulk/512/cancel');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.cancel_bulk_job(512)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.cancelBulkJob(512);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->cancelBulkJob(512);
  '/api/v1/email/bulk/{jobId}/resume':
    post:
      operationId: resume-bulk-job
      tags:
        - Bulk
      summary: 'Resume a bulk job that ran out of credits'
      description: |
        Runs a job that stopped when the credit balance ran out again, from the top of the list it was given, instead of asking you to upload it a second time. Rows verified in the last seven days come back as free repeats, so only the rows the stopped run never reached are charged.

        The job keeps its id, its webhook and its cumulative `credits_used`, which is the real spend on the account. `processed` and the verdict counts restart at zero: the part files they were counted from are deleted when a run settles, so the resumed run rebuilds them, and the result file the stopped run left is replaced as the rows come back.

        Only a run whose `stopped_reason` is `insufficient_credits` and whose `status` has settled can be resumed; anything else answers `409 JOB_NOT_RESUMABLE`, and so does a second resume of a run that is already going again — claiming the run is one conditional write, so two calls can never queue it twice. A run whose list has passed out of the retention window answers `409 JOB_INPUT_EXPIRED`. An empty balance answers `402 INSUFFICIENT_CREDITS` rather than queueing a run that would stop again on the first row it never reached.
      x-spaw-auth: bearer
      x-spaw-billing: 'Free to call. The resumed run bills the rows it reaches exactly as the first one did, and a row answered in the last seven days is a free repeat.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - JOB_NOT_RESUMABLE
        - JOB_INPUT_EXPIRED
        - INSUFFICIENT_CREDITS
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
      responses:
        202:
          description: 'The list is queued again.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        $ref: '#/components/schemas/BulkJob'
              example:
                success: true
                data:
                  job:
                    id: 512
                    status: queued
                    total: 2000
                    duplicate_count: 12
                    processed: 0
                    deliverable: 0
                    risky: 0
                    undeliverable: 0
                    credits_used: 750
                    stopped_reason: null
                    cancel_requested: false
                    webhook_status: null
                    created_at: '2026-09-03T10:12:44+00:00'
                    finished_at: null
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        404:
          $ref: '#/components/responses/NotFound'
        409:
          description: 'The run is not in a state a resume can recover (`JOB_NOT_RESUMABLE`), or its list is no longer stored (`JOB_INPUT_EXPIRED`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: JOB_NOT_RESUMABLE
                  message: 'Only a run that stopped because the credit balance ran out can be resumed, and only once it has finished settling.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/email/bulk/{jobId}/resume \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/bulk/{jobId}/resume', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/email/bulk/{jobId}/resume',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/bulk/{jobId}/resume');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.resume_bulk_job(job_id)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.resumeBulkJob(jobId);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->resumeBulkJob($jobId);
  '/api/v1/email/bulk/{jobId}/webhook/redeliver':
    post:
      operationId: redeliver-bulk-webhook
      tags:
        - Bulk
      summary: "Send a finished run's completion webhook again"
      description: |
        POSTs a finished run's completion webhook to its `webhook_url` a second time. This is for the case the webhook exists to cover: the run finished, the delivery went out, your endpoint was down for the few minutes it was tried, and the message is gone. `webhook_status` and `webhook_detail` tell you that happened; this is how you ask for it again, instead of falling back to polling — which is the thing the webhook was set up to avoid.

        **The body is the same body.** It is rebuilt from the run's own row rather than replayed from a stored blob, and every field it carries — `status`, `processed`, the verdict counts, `credits_used`, `stopped_reason`, `finished_at` — is frozen once a run has settled. The bytes are the ones the first delivery carried, so reconciling a redelivery against the original compares equals. (A run that is resumed later settles again and posts a new completion webhook of its own; a redelivery always repeats the most recent one.)

        **The signature is new, and that is correct.** Each delivery is signed as it is sent, so `X-Spaw-Signature-V2` carries a later `t` and a different `v2` digest from the first attempt. That is required rather than incidental: a receiver refuses anything whose `t` is more than five minutes from its own clock, so a signature copied from the original would be refused on arrival. Verify a redelivery exactly as you verify any other delivery. The untimestamped `X-Spaw-Signature` is the HMAC of the body alone, so it *is* identical to the first attempt's, until it stops being sent on 1 March 2027.

        **Nothing deduplicates this for you.** Each call is one real POST, so a receiver that gets both the original and the redelivery sees the event twice. Both bodies carry `job.id`: treat a `bulk_email_job.finished` for a job id you have already handled as a repeat and drop it.

        **The delivery is queued, and the answer is `202`.** One attempt is a 10-second timeout retried twice, about half a minute against a receiver that is down — which is the receiver you are asking about. So this call does not wait for it: it answers `202` at once with the run, and `webhook_status` on it reads `pending`, a delivery asked for and not yet answered. Poll the run (`GET /api/v1/email/bulk/{jobId}`) until `webhook_status` is `delivered` or `failed`; `webhook_detail` then carries the HTTP status your endpoint gave, or the transport failure when it gave none. A `pending` run has one delivery in flight and asking again queues a second — wait for the first before you decide it failed.

        A run created without a `webhook_url` answers `409 WEBHOOK_NOT_CONFIGURED`, which is checked FIRST because waiting will never fix it; one still queued or running answers `409 JOB_NOT_FINISHED`, because there is no completion to announce yet, and so does a run that stops being finished between the check and the queueing (a resume in another window). A run whose last delivery *succeeded* is not refused — a receiver can lose a message it has already acknowledged, and only you know whether it did.

        A URL that resolves onto a private address is refused at delivery here exactly as on the first attempt, every time, because DNS can be repointed after a URL is saved.
      x-spaw-auth: bearer
      x-spaw-billing: "Free. A redelivery spends no credits and bills nothing. It is capped at 10 a minute counted per signed-in user — neither per key nor per account, so two keys held by one person share the one budget while two teammates have one each, and the dashboard button and the other products' redeliveries all spend the caller's own."
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - WEBHOOK_NOT_CONFIGURED
        - JOB_NOT_FINISHED
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
          example: 512
          description: 'The id returned at creation.'
      responses:
        202:
          description: 'The delivery is queued; `webhook_status` reads `pending` until a worker has an answer.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        $ref: '#/components/schemas/BulkJob'
              example:
                success: true
                data:
                  job:
                    id: 512
                    status: completed
                    total: 2000
                    duplicate_count: 12
                    processed: 2000
                    deliverable: 1400
                    risky: 260
                    undeliverable: 340
                    credits_used: 1750
                    stopped_reason: null
                    cancel_requested: false
                    webhook_status: pending
                    webhook_detail: null
                    created_at: '2026-09-03T10:12:44+00:00'
                    finished_at: '2026-09-03T10:41:02+00:00'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        409:
          description: 'The run has no webhook URL (`WEBHOOK_NOT_CONFIGURED`), or it has not finished yet (`JOB_NOT_FINISHED`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: WEBHOOK_NOT_CONFIGURED
                  message: 'This run was created without a webhook URL, so there is no completion webhook to send.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          description: 'More than ten redeliveries in a minute from this user, across every product and the dashboard button alike. The shared limit named on other endpoints is not the one that fires here.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: RATE_LIMITED
                  message: 'Too many requests. Retry after the limit resets.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/email/bulk/512/webhook/redeliver \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/bulk/512/webhook/redeliver', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/email/bulk/512/webhook/redeliver',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/bulk/512/webhook/redeliver');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.redeliver_bulk_webhook(512)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.redeliverBulkWebhook(512);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->redeliverBulkWebhook(512);
  '/api/v1/email/bulk/{jobId}/results':
    get:
      operationId: download-bulk-results
      tags:
        - Bulk
      summary: "Download a bulk job's results as CSV"
      description: |
        Streams the run's result file: a finished job's full results, or the partial file a mid-run cancellation kept. The file starts with the input's own columns (for an API list, a single `email` column; for a dashboard upload, every column of the uploaded file), followed by `verified_email` and the verdict fields, one row per input row in the original order:

        `verified_email, deliverable, reason, risk_score, risk_level, risk_signals, normalized_email, is_alias, is_gibberish, looks_generated, local_part_pattern, first_name, last_name, disposable, is_relay, relay_service, role, role_type, free_provider, domain_category, mx_found, mx_implicit, mx_provider, mx_provider_type, mx_blocklisted, mx_network_type, has_spf, dmarc_policy, domain_registered_at, domain_age_days, domain_expires_at, parked, smtp_checked, smtp_checked_at, mailbox_exists, previously_invalid, previously_invalid_at, catch_all, smtp_reason, mailbox_confidence, widely_seen, did_you_mean, ok_to_send, blocked_by, credits_used, cache_hit, suppressed`

        Add `?variant=deliverable` (or `risky`, `undeliverable`) to download only those rows. A job still running answers `409 RESULTS_NOT_READY`; one cancelled before any address was processed answers `409 JOB_CANCELLED`, and one that failed before any address was processed `409 JOB_FAILED`. A run that was cancelled or failed part-way keeps the rows it did write, and they stay downloadable.

        `format=json` answers the same rows, filtered the same way, as objects keyed by the result file's own header instead of a CSV attachment: `{ "success": true, "data": { "job": …, "results": [ … ] }, "meta": { "variant", "request_id" } }`. It is streamed row by row exactly as the CSV is, so a 100,000-row run costs no more to answer either way. A CSV cell carries no type, so only the two the writer encodes come back typed: an empty cell is `null` and `true`/`false` are booleans. Everything else is the string as written — a number is never silently made numeric, because postcodes, phone numbers and house numbers lose their leading zeros that way — and the columns of an uploaded file come back exactly as they were uploaded.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RESULTS_NOT_READY
        - JOB_CANCELLED
        - JOB_FAILED
        - VALIDATION_FAILED
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
          example: 512
          description: 'The id returned at creation.'
        -
          name: variant
          in: query
          required: false
          schema:
            type: string
            enum:
              - full
              - deliverable
              - risky
              - undeliverable
            default: full
          example: deliverable
          description: 'Which rows to include.'
        -
          name: format
          in: query
          required: false
          schema:
            type: string
            enum:
              - csv
              - json
            default: csv
          example: json
          description: '`csv` streams the file as an attachment; `json` streams the same rows as objects.'
      responses:
        200:
          description: 'The result rows: a CSV attachment named `spaw-bulk-{jobId}.csv` (with `-{variant}` appended for a filtered download), or streamed JSON with `format=json`.'
          content:
            text/csv:
              schema:
                type: string
              example: |
                email,verified_email,deliverable,reason,risk_score,risk_level,risk_signals,normalized_email,is_alias,is_gibberish,looks_generated,local_part_pattern,first_name,last_name,disposable,is_relay,relay_service,role,role_type,free_provider,domain_category,mx_found,mx_implicit,mx_provider,mx_provider_type,mx_blocklisted,mx_network_type,has_spf,dmarc_policy,domain_registered_at,domain_age_days,domain_expires_at,parked,smtp_checked,smtp_checked_at,mailbox_exists,previously_invalid,previously_invalid_at,catch_all,smtp_reason,mailbox_confidence,widely_seen,did_you_mean,ok_to_send,blocked_by,credits_used,cache_hit,suppressed
                mia@acme.com,mia@acme.com,deliverable,,0,low,,mia@acme.com,false,false,false,first,mia,,false,false,,false,,false,other,true,false,google,mailbox_provider,false,hosting,true,reject,1998-03-12,10402,2027-03-12,false,true,2026-09-03T10:12:44+00:00,true,false,,false,,,false,,true,,1,false,false
            application/json:
              schema:
                $ref: '#/components/schemas/BulkResultRows'
              example:
                success: true
                data:
                  job:
                    id: 512
                    status: completed
                  results:
                    -
                      email: mia@acme.com
                      verified_email: mia@acme.com
                      deliverable: deliverable
                      reason: null
                      risk_score: '0'
                      risk_level: low
                      mx_provider: google
                      smtp_checked: true
                      credits_used: '1'
                      cache_hit: false
                      suppressed: false
                meta:
                  variant: full
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        409:
          description: 'The results are not available yet (`RESULTS_NOT_READY`) or never will be (`JOB_CANCELLED`, `JOB_FAILED`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: RESULTS_NOT_READY
                  message: 'The job has not finished yet. Poll its status until it reports completed.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        422:
          description: 'The `variant` query parameter is not one of the allowed values.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: VALIDATION_FAILED
                  message: 'variant must be one of: full, deliverable, risky, undeliverable.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/email/bulk/512/results \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/bulk/512/results', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/email/bulk/512/results',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/bulk/512/results');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.download_bulk_results(512)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.downloadBulkResults(512);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->downloadBulkResults(512);
  /api/v1/email/suppressions:
    get:
      operationId: list-suppressions
      tags:
        - Suppressions
      summary: 'List the suppression list'
      description: |
        The account's suppression list, newest first, 100 entries per page. Every undeliverable verdict adds the address in normalized form (lowercased, plus-tag stripped, Gmail dots folded) with source `auto`; imports arrive with source `imported`; bounces and complaints reported through the feedback endpoints arrive with source `feedback`. Batch, bulk and monitor runs answer suppressed addresses from the stored verdict at no cost; a single lookup always re-verifies, and a verdict other than undeliverable removes only an entry with source `auto`; a bounce you reported leaves when you report a delivery to that address, and an import or a complaint only when you remove it. A complaint about an address you imported is recorded on that entry (reason `complained`), which stays an import. Imports and complaints never go stale: they are served for as long as they stand. Any other entry untouched for 90 days is re-verified instead of trusted.

        Every filter is optional and they combine. `value` looks one address up: it is normalized the same way the list stores it, so `Mia+news@Acme.com` finds the entry filed as `mia@acme.com`. An address that does not parse matches nothing. An unparseable date or an out-of-range `per_page` is a `422`, not a silently unfiltered page.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      parameters:
        -
          name: value
          in: query
          required: false
          schema:
            type: string
            maxLength: 500
          example: Mia+news@Acme.com
          description: 'One address, in any spelling of it. Normalized before it is matched.'
        -
          name: source
          in: query
          required: false
          schema:
            type: string
            enum:
              - auto
              - imported
              - feedback
          example: imported
          description: 'Only entries this source added.'
        -
          name: reason
          in: query
          required: false
          schema:
            type: string
            maxLength: 64
          example: mailbox_not_found
          description: 'Only entries carrying this verdict reason.'
        -
          name: since
          in: query
          required: false
          schema:
            type: string
            format: date
          example: '2026-08-01'
          description: 'Only entries added on or after this date.'
        -
          name: until
          in: query
          required: false
          schema:
            type: string
            format: date
          example: '2026-09-01'
          description: 'Only entries added on or before this date. A bare date means the whole of that day.'
        -
          name: page
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            default: 1
          example: 1
          description: 'The page to return.'
        -
          name: per_page
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
          example: 100
          description: 'Entries per page, up to 500.'
      responses:
        200:
          description: 'One page of entries.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      suppressions:
                        type: array
                        items:
                          $ref: '#/components/schemas/Suppression'
                  meta:
                    type: object
                    properties:
                      page:
                        type: integer
                      per_page:
                        type: integer
                      total:
                        type: integer
              example:
                success: true
                data:
                  suppressions:
                    -
                      id: 9041
                      email: old@acme.com
                      reason: mailbox_not_found
                      source: auto
                      added_at: '2026-09-02T08:30:00+00:00'
                    -
                      id: 9040
                      email: bounced@example.org
                      reason: null
                      source: imported
                      added_at: '2026-09-01T17:02:11+00:00'
                meta:
                  page: 1
                  per_page: 100
                  total: 2
        401:
          $ref: '#/components/responses/Unauthenticated'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/email/suppressions \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/suppressions', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/email/suppressions',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/suppressions');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.list_suppressions()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.listSuppressions();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->listSuppressions();
    post:
      operationId: add-suppressions
      tags:
        - Suppressions
      summary: 'Import known-bad addresses'
      description: |
        Adds up to 10,000 addresses per request to the suppression list — an email provider's bounce export, a hand-kept blocklist. Addresses are normalized first; ones that do not parse are counted as `invalid`. An address already on the list keeps the verdict stored with it — a verified answer is better to serve than a synthesized one — is counted as `already_suppressed`, and becomes an import too; that verdict is served for 90 days after the entry was last renewed, then the import answers with reason `suppressed`, because nothing verifies an import again. Imported entries are honored from day one at no credit cost: list, bulk and monitor runs answer them with reason `suppressed` and `risk_score` 100. An import is your decision rather than a verdict, so no lookup clears it — not a single re-verify, not one through your publishable key — and no delivery report does either: it leaves the list only when you remove it. A complaint reported about it later is recorded on the entry (reason `complained`, still source `imported`). An import never goes stale, so importing an address again is never needed.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - emails
              properties:
                emails:
                  type: array
                  minItems: 1
                  maxItems: 10000
                  items:
                    type: string
                    maxLength: 254
                  description: '1 to 10,000 addresses; each item at most 254 characters.'
            example:
              emails:
                - bounced@example.org
                - gone@acme.com
      responses:
        200:
          description: 'How many entries were added, already present, or unparseable.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      added:
                        type: integer
                      already_suppressed:
                        type: integer
                      invalid:
                        type: integer
              example:
                success: true
                data:
                  added: 2
                  already_suppressed: 0
                  invalid: 0
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/email/suppressions \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "emails": [
                "bounced@example.org",
                "gone@acme.com"
              ]
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/suppressions', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "emails": [
                  "bounced@example.org",
                  "gone@acme.com"
                ]
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/email/suppressions',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'emails': [
                        'bounced@example.org',
                        'gone@acme.com'
                    ]
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/suppressions');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'emails' => [
                        'bounced@example.org',
                        'gone@acme.com'
                    ]
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.add_suppressions([
                'bounced@example.org',
                'gone@acme.com'
            ])
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.addSuppressions([
                'bounced@example.org',
                'gone@acme.com'
            ]);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->addSuppressions([
                'bounced@example.org',
                'gone@acme.com'
            ]);
    delete:
      operationId: remove-suppressions
      tags:
        - Suppressions
      summary: 'Remove addresses from the suppression list'
      description: |
        Removes up to 1,000 addresses per request by the address itself, for a caller that knows the address but not the id it was filed under. Each value is normalized the way the list stores it first, so `Mia+news@Acme.com` removes the entry filed as `mia@acme.com`.

        The answer names every address it was given, spelled the way you sent it: `removed` are the ones that were on the list and no longer are, `not_found` the ones that were not on it — including an address that does not parse, and one that is on another account's list. Two spellings of one mailbox both count as removed.

        The cap is 1,000 rather than the import's 10,000 because the answer names every value back: a request over the cap is a `422` and removes nothing. Removing an entry means the address is verified fresh the next time a list, bulk or monitor run meets it.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - values
              properties:
                values:
                  type: array
                  minItems: 1
                  maxItems: 1000
                  items:
                    type: string
                    maxLength: 254
                  description: '1 to 1,000 addresses, in any spelling of each.'
            example:
              values:
                - Mia+news@Acme.com
                - nobody@example.org
      responses:
        200:
          description: 'Which of the addresses were on the list and which were not.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      removed:
                        type: array
                        items:
                          type: string
                        description: 'The addresses that were on the list, as you sent them.'
                      not_found:
                        type: array
                        items:
                          type: string
                        description: 'The addresses that were not on the list, as you sent them.'
              example:
                success: true
                data:
                  removed:
                    - Mia+news@Acme.com
                  not_found:
                    - nobody@example.org
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X DELETE https://spaw.co/api/v1/email/suppressions \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "values": [
                "Mia+news@Acme.com",
                "nobody@example.org"
              ]
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/suppressions', {
              method: 'DELETE',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "values": [
                  "Mia+news@Acme.com",
                  "nobody@example.org"
                ]
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.delete(
                'https://spaw.co/api/v1/email/suppressions',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'values': [
                        'Mia+news@Acme.com',
                        'nobody@example.org'
                    ]
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/suppressions');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'DELETE',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'values' => [
                        'Mia+news@Acme.com',
                        'nobody@example.org'
                    ]
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.remove_suppressions([
                'Mia+news@Acme.com',
                'nobody@example.org'
            ])
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.removeSuppressions([
                'Mia+news@Acme.com',
                'nobody@example.org'
            ]);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->removeSuppressions([
                'Mia+news@Acme.com',
                'nobody@example.org'
            ]);
  '/api/v1/email/suppressions/{suppressionId}':
    delete:
      operationId: remove-suppression
      tags:
        - Suppressions
      summary: 'Remove a suppression entry'
      description: |
        Deletes one entry so the address is verified fresh the next time a list, bulk or monitor run meets it. An entry that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: suppressionId
          in: path
          required: true
          schema:
            type: integer
          example: 9041
          description: "The entry's id from the list endpoint."
      responses:
        200:
          description: 'The entry was removed.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
              example:
                success: true
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X DELETE https://spaw.co/api/v1/email/suppressions/9041 \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/suppressions/9041', {
              method: 'DELETE',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.delete(
                'https://spaw.co/api/v1/email/suppressions/9041',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/suppressions/9041');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'DELETE',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.remove_suppression(9041)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.removeSuppression(9041);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->removeSuppression(9041);
  /api/v1/email/monitors:
    get:
      operationId: list-monitors
      tags:
        - Monitors
      summary: "List the account's monitors"
      description: |
        The saved lists this account re-verifies on a schedule, newest first, 100 per page. Each monitor carries the summary of its last run: the verdict counts, how many addresses decayed (deliverable last time, not any more), the credits the run spent, and `stopped_reason` when it did not finish cleanly: `insufficient_credits` or `key_spend_cap` where the run stopped part-way, `run_failed` where it died outright, and `run_failed_retrying` where it died and the schedule has been pulled in to check again — `retry_at` in the same block says when that becomes due, and the hourly sweep picks it up at or after that, so between 45 minutes and about an hour and three quarters later. A summary carrying a `stopped_reason` of either failure kind has NO counts in it at all: the run produced none, so read a missing count as unknown rather than as zero. The addresses and their verdicts are on the single-monitor endpoint.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - RATE_LIMITED
      parameters:
        -
          name: page
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            default: 1
          example: 1
          description: 'The page to return.'
      responses:
        200:
          description: 'One page of monitors.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitors:
                        type: array
                        items:
                          $ref: '#/components/schemas/Monitor'
                  meta:
                    type: object
                    properties:
                      page:
                        type: integer
                      per_page:
                        type: integer
                      total:
                        type: integer
                      retention_days:
                        type: integer
                        description: 'How long a list nobody runs is kept before it is deleted.'
              example:
                success: true
                data:
                  monitors:
                    -
                      id: 41
                      name: 'Newsletter list'
                      cadence: weekly
                      email_count: 2
                      next_run_at: '2026-09-10T08:00:00+00:00'
                      last_run_at: '2026-09-03T08:00:12+00:00'
                      last_summary:
                        deliverable: 1
                        risky: 1
                        undeliverable: 0
                        decayed: 0
                        credits_used: 2
                        stopped_reason: null
                      created_at: '2026-08-27T08:00:00+00:00'
                meta:
                  page: 1
                  per_page: 100
                  total: 1
                  retention_days: 365
        401:
          $ref: '#/components/responses/Unauthenticated'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/email/monitors \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/monitors', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/email/monitors',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/monitors');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.list_monitors()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.listMonitors();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->listMonitors();
    post:
      operationId: create-monitor
      tags:
        - Monitors
      summary: 'Monitor a list on a schedule'
      description: |
        Saves up to 500 addresses and re-verifies them every week or every month. The first run starts right away and only sets the baseline; from the next run on, every address that was deliverable last time and no longer is counts as decayed and is reported to the account's email. Runs honor the suppression list and the 7-day repeat cache, so a stable list costs little to keep watching.

        Answers `201` with the monitor. The baseline run is queued, not finished: poll the monitor for `last_run_at` and `last_summary`.

        With a `webhook_url`, a finished run is POSTed there as `{ "event": "email_monitor.run", "monitor": { "id", "name", "cadence", "last_run_at", "next_run_at" }, "summary": { … the same block as `last_summary` … }, "changes": [ … ] }`, where `changes` names the addresses the alert email names. The `X-Spaw-Signature-V2` header carries `t={unix seconds},v2={lowercase hex}`, where the hex is the HMAC-SHA256 of `v2:{t}:{raw body}` keyed with `webhook_secret`, which this response returns once; a receiver should refuse anything whose `t` is more than five minutes from its own clock, since a signature over the body alone would let a captured delivery be replayed at it forever. The original `X-Spaw-Signature`, that HMAC of the body alone, is sent beside it until 1 March 2027. Connection errors and 5xx answers are retried twice with a short backoff, three attempts in all, and the outcome is readable afterwards as `webhook_status` and `webhook_detail`. A URL that resolves onto a private network is never posted to.
      x-spaw-auth: bearer
      x-spaw-billing: 'Creation is free. Each run bills every address like a single lookup — 1 credit for a fresh deliverable or risky verdict whose mailbox check was definite; unverified answers, suppressed addresses and 7-day repeats are free, and undeliverable verdicts are free within your email fair use and 1 credit each past it.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
                - emails
                - cadence
              properties:
                name:
                  type: string
                  maxLength: 100
                  description: 'A label for the dashboard and the decay alerts.'
                emails:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type: string
                    maxLength: 254
                  description: '1 to 500 addresses; each item at most 254 characters.'
                cadence:
                  type: string
                  enum:
                    - weekly
                    - monthly
                  description: 'How often the list is re-verified.'
                webhook_url:
                  type:
                    - string
                    - 'null'
                  maxLength: 2048
                  format: uri
                  description: 'An https URL every reported run is POSTed to. The signing secret is returned once, in this response, and never again.'
                webhook_events:
                  type: string
                  enum:
                    - changes
                    - every_run
                  default: changes
                  description: 'When to post. `changes` posts only when the run found something new — decay, a status change, a newly flagged entry — or stopped early; `every_run` posts after every run, which also says the monitor is still running.'
            example:
              name: 'Newsletter list'
              emails:
                - mia@acme.com
                - ben@acme.com
              cadence: weekly
      responses:
        201:
          description: 'The monitor was saved and its baseline run queued.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        allOf:
                          -
                            $ref: '#/components/schemas/Monitor'
                          -
                            type: object
                            properties:
                              webhook_secret:
                                type:
                                  - string
                                  - 'null'
                                description: 'The webhook signing secret, shown once here and never readable again. Null when the monitor has no webhook.'
              example:
                success: true
                data:
                  monitor:
                    id: 41
                    name: 'Newsletter list'
                    cadence: weekly
                    email_count: 2
                    next_run_at: '2026-09-10T10:12:44+00:00'
                    last_run_at: null
                    last_summary: null
                    created_at: '2026-09-03T10:12:44+00:00'
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/email/monitors \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "name": "Newsletter list",
              "emails": [
                "mia@acme.com",
                "ben@acme.com"
              ],
              "cadence": "weekly"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/monitors', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "name": "Newsletter list",
                "emails": [
                  "mia@acme.com",
                  "ben@acme.com"
                ],
                "cadence": "weekly"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/email/monitors',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'name': 'Newsletter list',
                    'emails': [
                        'mia@acme.com',
                        'ben@acme.com'
                    ],
                    'cadence': 'weekly'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/monitors');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'name' => 'Newsletter list',
                    'emails' => [
                        'mia@acme.com',
                        'ben@acme.com'
                    ],
                    'cadence' => 'weekly'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.create_monitor(name='Newsletter list', emails=[
                'mia@acme.com',
                'ben@acme.com'
            ], cadence='weekly')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.createMonitor({
                name: 'Newsletter list',
                emails: [
                    'mia@acme.com',
                    'ben@acme.com'
                ],
                cadence: 'weekly'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->createMonitor(name: 'Newsletter list', emails: [
                'mia@acme.com',
                'ben@acme.com'
            ], cadence: 'weekly');
  '/api/v1/email/monitors/{monitorId}':
    get:
      operationId: get-monitor
      tags:
        - Monitors
      summary: 'Read a monitor and its last results'
      description: |
        The monitor plus `results`: the verdict of every address from the last run, keyed by the address as it was saved, or null until the first run has finished. A monitor that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
          example: 41
          description: "The monitor's id from the list endpoint."
      responses:
        200:
          description: 'The monitor.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        allOf:
                          -
                            $ref: '#/components/schemas/Monitor'
                          -
                            type: object
                            properties:
                              results:
                                type:
                                  - object
                                  - 'null'
                                description: "The last run's verdict per address (deliverable, risky or undeliverable), keyed by the address; null until the first run finishes."
              example:
                success: true
                data:
                  monitor:
                    id: 41
                    name: 'Newsletter list'
                    cadence: weekly
                    email_count: 2
                    next_run_at: '2026-09-10T08:00:00+00:00'
                    last_run_at: '2026-09-03T08:00:12+00:00'
                    last_summary:
                      deliverable: 1
                      risky: 1
                      undeliverable: 0
                      decayed: 0
                      credits_used: 2
                      stopped_reason: null
                    created_at: '2026-08-27T08:00:00+00:00'
                    results:
                      mia@acme.com: deliverable
                      ben@acme.com: risky
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/email/monitors/41 \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/monitors/41', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/email/monitors/41',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/monitors/41');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.get_monitor(41)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.getMonitor(41);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->getMonitor(41);
    patch:
      operationId: update-monitor
      tags:
        - Monitors
      summary: 'Change a monitor'
      description: |
        Changes a saved monitor in place: rename it, move it to another cadence, replace the addresses, or point it at a webhook. Send only the fields you are changing; a body that names none of them answers `422` rather than quietly changing nothing, because that is the shape a misspelled field name takes.

        Replacing `emails` replaces the list. What the last run recorded about the addresses that stayed is kept, so their next run still compares against the answer they already had; what it recorded about the ones that are gone is deleted with them; and an entry that is new starts with no baseline, so its first run only sets one. A rename never touches a stored answer — they are keyed by the entry, never by the list.

        Changing `cadence` neither skips a run nor starts a second one: a run already scheduled sooner than the new interval still happens, and a longer interval applies from the one after it. Nothing is queued by this call — the change applies to the next run, and `POST .../run` is there to have it now.

        Sending a `webhook_url` that differs from the one stored mints a new signing secret and returns it once, here; sending the URL it already has keeps the secret it is signing with, and `null` removes the webhook. A monitor that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: 'Free. The change applies to the next run.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - VALIDATION_FAILED
        - RATE_LIMITED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
          example: 41
          description: "The monitor's id from the list endpoint."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                name:
                  type: string
                  maxLength: 100
                emails:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type: string
                    maxLength: 254
                cadence:
                  type: string
                  enum:
                    - weekly
                    - monthly
                webhook_url:
                  type:
                    - string
                    - 'null'
                  maxLength: 2048
                  format: uri
                webhook_events:
                  type: string
                  enum:
                    - changes
                    - every_run
            example:
              name: 'Newsletter list (2026)'
              cadence: monthly
      responses:
        200:
          description: 'The monitor as it now stands.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        allOf:
                          -
                            $ref: '#/components/schemas/Monitor'
                          -
                            type: object
                            properties:
                              webhook_secret:
                                type: string
                                description: 'Present only when this call pointed the monitor at a URL it was not already posting to. Shown once.'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X PATCH https://spaw.co/api/v1/email/monitors/41 \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "name": "Newsletter list (2026)",
              "cadence": "monthly"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/monitors/41', {
              method: 'PATCH',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "name": "Newsletter list (2026)",
                "cadence": "monthly"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.patch(
                'https://spaw.co/api/v1/email/monitors/41',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'name': 'Newsletter list (2026)',
                    'cadence': 'monthly'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/monitors/41');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'PATCH',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'name' => 'Newsletter list (2026)',
                    'cadence' => 'monthly'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.update_monitor(41, name='Newsletter list (2026)', cadence='monthly')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.updateMonitor(41, {
                name: 'Newsletter list (2026)',
                cadence: 'monthly'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->updateMonitor(41, [
                'name' => 'Newsletter list (2026)',
                'cadence' => 'monthly'
            ]);
    delete:
      operationId: delete-monitor
      tags:
        - Monitors
      summary: 'Stop monitoring a list'
      description: |
        Removes the monitor and its stored results; nothing further is scheduled. A run that is already queued finishes on its own and bills as usual. A monitor that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
          example: 41
          description: "The monitor's id from the list endpoint."
      responses:
        200:
          description: 'The monitor was removed.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
              example:
                success: true
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X DELETE https://spaw.co/api/v1/email/monitors/41 \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/monitors/41', {
              method: 'DELETE',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.delete(
                'https://spaw.co/api/v1/email/monitors/41',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/monitors/41');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'DELETE',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.delete_monitor(41)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.deleteMonitor(41);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->deleteMonitor(41);
  '/api/v1/email/monitors/{monitorId}/run':
    post:
      operationId: run-monitor
      tags:
        - Monitors
      summary: 'Re-verify a monitored list now'
      description: |
        Queues a run right away instead of waiting for the schedule; when it finishes, the next scheduled run is set one cadence from then. Answers `202` with the monitor as it stands before the run; poll it for the new `last_run_at`, `last_summary` and `results`. A monitor that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: 'The run bills every address like a single lookup; suppressed addresses and 7-day repeats are free.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
          example: 41
          description: "The monitor's id from the list endpoint."
      responses:
        202:
          description: 'The run was queued.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        $ref: '#/components/schemas/Monitor'
              example:
                success: true
                data:
                  monitor:
                    id: 41
                    name: 'Newsletter list'
                    cadence: weekly
                    email_count: 2
                    next_run_at: '2026-09-10T08:00:00+00:00'
                    last_run_at: '2026-09-03T08:00:12+00:00'
                    last_summary:
                      deliverable: 1
                      risky: 1
                      undeliverable: 0
                      decayed: 0
                      credits_used: 2
                      stopped_reason: null
                    created_at: '2026-08-27T08:00:00+00:00'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/email/monitors/41/run \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/monitors/41/run', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/email/monitors/41/run',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/monitors/41/run');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.run_monitor(41)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.runMonitor(41);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->runMonitor(41);
  /api/v1/email/feedback:
    post:
      operationId: report-delivery-feedback
      tags:
        - Feedback
      summary: 'Report delivery outcomes'
      description: |
        Tells Spaw what actually happened to an address after you sent to it: `delivered`, `bounced` (hard bounces only), or `complained`. Each outcome is stored against the verdict you had been given for that address in the previous 90 days, which turns verdicts into measured accuracy, and it keeps your suppression list current: a bounce or complaint adds the address with source `feedback`, and a delivery removes an entry a verdict or a bounce added. A complaint is the recipient's objection, not a fact about the mailbox: no delivery, lookup or later bounce clears or relabels it and it never goes stale, so like an import it leaves only when you remove it. On an address you imported, a complaint is recorded on the import (reason `complained`, source still `imported`) and a bounce renews it without changing its reason; a delivery never clears an import. An address you reported delivered within the last 90 days also counts as a confirmed mailbox for your own later lookups (`smtp_reason: "delivered_recently"`), without a probe.

        Feedback is free and never logged as a lookup. Items whose address does not parse are skipped and counted, not rejected. Outcomes are kept for 180 days.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - items
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 1000
                  items:
                    $ref: '#/components/schemas/FeedbackItem'
                  description: '1 to 1,000 outcomes per request.'
            example:
              items:
                -
                  email: mia@acme.com
                  outcome: bounced
                  reason: '550 5.1.1 no such user'
                -
                  email: sam@example.org
                  outcome: delivered
                  occurred_at: '2026-09-03T09:00:00Z'
      responses:
        202:
          description: 'The outcomes were recorded.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      recorded:
                        type: integer
                      skipped:
                        type: integer
                        description: 'Items whose address did not parse.'
              example:
                success: true
                data:
                  recorded: 2
                  skipped: 0
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/email/feedback \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "items": [
                {
                  "email": "mia@acme.com",
                  "outcome": "bounced",
                  "reason": "550 5.1.1 no such user"
                },
                {
                  "email": "sam@example.org",
                  "outcome": "delivered",
                  "occurred_at": "2026-09-03T09:00:00Z"
                }
              ]
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/feedback', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "items": [
                  {
                    "email": "mia@acme.com",
                    "outcome": "bounced",
                    "reason": "550 5.1.1 no such user"
                  },
                  {
                    "email": "sam@example.org",
                    "outcome": "delivered",
                    "occurred_at": "2026-09-03T09:00:00Z"
                  }
                ]
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/email/feedback',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'items': [
                        {
                            'email': 'mia@acme.com',
                            'outcome': 'bounced',
                            'reason': '550 5.1.1 no such user'
                        },
                        {
                            'email': 'sam@example.org',
                            'outcome': 'delivered',
                            'occurred_at': '2026-09-03T09:00:00Z'
                        }
                    ]
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/feedback');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'items' => [
                        [
                            'email' => 'mia@acme.com',
                            'outcome' => 'bounced',
                            'reason' => '550 5.1.1 no such user'
                        ],
                        [
                            'email' => 'sam@example.org',
                            'outcome' => 'delivered',
                            'occurred_at' => '2026-09-03T09:00:00Z'
                        ]
                    ]
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.report_feedback([
                {
                    'email': 'mia@acme.com',
                    'outcome': 'bounced',
                    'reason': '550 5.1.1 no such user'
                },
                {
                    'email': 'sam@example.org',
                    'outcome': 'delivered',
                    'occurred_at': '2026-09-03T09:00:00Z'
                }
            ])
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.reportFeedback([
                {
                    email: 'mia@acme.com',
                    outcome: 'bounced',
                    reason: '550 5.1.1 no such user'
                },
                {
                    email: 'sam@example.org',
                    outcome: 'delivered',
                    occurred_at: '2026-09-03T09:00:00Z'
                }
            ]);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->reportFeedback([
                [
                    'email' => 'mia@acme.com',
                    'outcome' => 'bounced',
                    'reason' => '550 5.1.1 no such user'
                ],
                [
                    'email' => 'sam@example.org',
                    'outcome' => 'delivered',
                    'occurred_at' => '2026-09-03T09:00:00Z'
                ]
            ]);
  /api/v1/email/feedback/summary:
    get:
      operationId: feedback-summary
      tags:
        - Feedback
      summary: 'Measured accuracy'
      description: |
        Crosses the last 90 days of reported outcomes with the verdicts on file. `by_verdict` counts delivered, bounced and complained per verdict (`deliverable`, `risky`, `undeliverable`, and `unknown` for addresses with no verdict on file), and `measured.deliverable_delivery_rate` and `measured.undeliverable_bounce_rate` are the two numbers that say how right Spaw was for you. A rate is `null` until there is at least one matching outcome. The same figures appear on the History tab of the dashboard.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - RATE_LIMITED
      responses:
        200:
          description: 'The summary.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/FeedbackSummary'
              example:
                success: true
                data:
                  window_days: 90
                  total: 1240
                  outcomes:
                    delivered: 1180
                    bounced: 52
                    complained: 8
                  by_verdict:
                    deliverable:
                      delivered: 1150
                      bounced: 9
                      complained: 6
                    risky:
                      delivered: 30
                      bounced: 11
                      complained: 2
                    undeliverable:
                      delivered: 0
                      bounced: 32
                      complained: 0
                    unknown:
                      delivered: 0
                      bounced: 0
                      complained: 0
                  measured:
                    deliverable_delivery_rate: 0.992
                    undeliverable_bounce_rate: 1
        401:
          $ref: '#/components/responses/Unauthenticated'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/email/feedback/summary \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/feedback/summary', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/email/feedback/summary',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/feedback/summary');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.feedback_summary()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.feedbackSummary();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->feedbackSummary();
  '/api/v1/email/feedback/{provider}/{feedbackKey}':
    post:
      operationId: esp-bounce-webhook
      tags:
        - Feedback
      summary: 'Receive bounce webhooks from an email provider'
      description: |
        The webhook target for an email provider's bounce, delivery and complaint notifications. Providers cannot send bearer tokens, so the account's feedback key rides in the URL (generate or rotate it on the dashboard's API keys page; rotating invalidates the URLs at once) and the provider name selects the payload adapter. The body is the provider's own payload, decoded as JSON regardless of content type because Amazon SNS posts JSON as `text/plain`. Payloads are read for their documented fields and nothing else; events that say nothing definitive about the address are ignored.

        | Provider | Path | Mapping |
        | --- | --- | --- |
        | postmark | `/api/v1/email/feedback/postmark/{key}` | Bounce (hard types) → bounced · Delivery → delivered · SpamComplaint → complained. Soft bounces are ignored. |
        | ses | `/api/v1/email/feedback/ses/{key}` | SNS notifications: Bounce with bounceType Permanent → bounced · Delivery → delivered · Complaint → complained. Subscription confirmations are answered. |
        | mailgun | `/api/v1/email/feedback/mailgun/{key}` | delivered → delivered · failed with severity permanent → bounced · complained → complained. Temporary failures are ignored. |
        | sendgrid | `/api/v1/email/feedback/sendgrid/{key}` | delivered → delivered · bounce and dropped → bounced · spamreport → complained. Deferred events are ignored. |

        Throttled at 120 requests per minute per IP. Outcomes recorded here behave exactly like ones reported through `POST /api/v1/email/feedback`.
      security: []
      x-spaw-auth: feedback-key
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNKNOWN_FEEDBACK_PROVIDER
        - INVALID_FEEDBACK_KEY
        - RATE_LIMITED
      parameters:
        -
          name: provider
          in: path
          required: true
          schema:
            type: string
            enum:
              - postmark
              - ses
              - mailgun
              - sendgrid
          example: postmark
          description: "Which provider's payload format to expect."
        -
          name: feedbackKey
          in: path
          required: true
          schema:
            type: string
          example: fb_…
          description: "The account's feedback key, which starts with `fb_`."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: "The provider's native webhook payload."
              additionalProperties: true
            example:
              RecordType: Bounce
              Type: HardBounce
              Email: mia@acme.com
              Description: 'The server was unable to deliver your message (ex. unknown user, mailbox not found).'
              BouncedAt: '2026-09-03T10:12:44Z'
      responses:
        200:
          description: 'The outcomes the payload carried were recorded.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      recorded:
                        type: integer
                      skipped:
                        type: integer
              example:
                success: true
                data:
                  recorded: 1
                  skipped: 0
        401:
          description: 'The feedback key does not exist or was rotated.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: INVALID_FEEDBACK_KEY
                  message: 'That feedback key does not exist or was rotated.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        404:
          description: 'The provider path is not one of postmark, ses, mailgun, or sendgrid.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: UNKNOWN_FEEDBACK_PROVIDER
                  message: 'Supported providers: postmark, ses, mailgun, sendgrid.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/email/feedback/postmark/fb_… \
              -H "Content-Type: application/json" \
              -d '{
              "RecordType": "Bounce",
              "Type": "HardBounce",
              "Email": "mia@acme.com",
              "Description": "The server was unable to deliver your message (ex. unknown user, mailbox not found).",
              "BouncedAt": "2026-09-03T10:12:44Z"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/feedback/postmark/fb_…', {
              method: 'POST',
              headers: {
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "RecordType": "Bounce",
                "Type": "HardBounce",
                "Email": "mia@acme.com",
                "Description": "The server was unable to deliver your message (ex. unknown user, mailbox not found).",
                "BouncedAt": "2026-09-03T10:12:44Z"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/email/feedback/postmark/fb_…',
                json={
                    'RecordType': 'Bounce',
                    'Type': 'HardBounce',
                    'Email': 'mia@acme.com',
                    'Description': 'The server was unable to deliver your message (ex. unknown user, mailbox not found).',
                    'BouncedAt': '2026-09-03T10:12:44Z'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/feedback/postmark/fb_…');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'RecordType' => 'Bounce',
                    'Type' => 'HardBounce',
                    'Email' => 'mia@acme.com',
                    'Description' => 'The server was unable to deliver your message (ex. unknown user, mailbox not found).',
                    'BouncedAt' => '2026-09-03T10:12:44Z'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
  '/api/v1/phone/feedback/{provider}/{feedbackKey}':
    post:
      operationId: sms-status-webhook
      tags:
        - Phone
      summary: 'Receive delivery status webhooks from an SMS provider'
      description: |
        The webhook target for an SMS provider's delivery status callbacks, the phone counterpart of the email bounce webhook. Providers cannot send bearer tokens, so the account's feedback key rides in the URL (the same key as for email, generated or rotated on the dashboard); the provider name picks the payload adapter. Twilio and MessageBird post form fields, Vonage and Sinch JSON; both are read. Only definitive statuses become outcomes; queued, sent, buffered, accepted and unknown are ignored.

        | Provider | Path | Mapping |
        | --- | --- | --- |
        | twilio | `/api/v1/phone/feedback/twilio/{key}` | Message status callback: delivered → delivered · undelivered and failed → undelivered. `To` is the number; the callback carries no timestamp, so the outcome is dated on receipt. |
        | vonage | `/api/v1/phone/feedback/vonage/{key}` | SMS API receipts (`msisdn`, `status`, `message-timestamp`) and Messages API status webhooks (`to`, `status`, `timestamp`): delivered → delivered · expired, failed, rejected and undeliverable → undelivered. |
        | messagebird | `/api/v1/phone/feedback/messagebird/{key}` | Status reports (`recipient`, `status`, `statusDatetime`), configured as POST: delivered → delivered · delivery_failed and expired → undelivered. |
        | sinch | `/api/v1/phone/feedback/sinch/{key}` | Per-recipient reports (`recipient`, `status`, `at`) and batch reports (`statuses[].recipients`): Delivered → delivered · Failed, Expired, Rejected and Aborted → undelivered. |

        A failed receipt's error code — Twilio `ErrorCode`, Vonage `err-code` (SMS API) or `error.title` (Messages API), MessageBird `statusErrorCode` (or, without one, `statusReason`), Sinch `code` — is read into the report's `failure_class` from each provider's published code list: `permanent` (Vonage 3 and 4, Messages API 1190, MessageBird 1, Sinch 150 and 151), `transient` (such as Twilio 30003, 30004, 30005 and 30008, Vonage 1, 2, 7 and 8), `not_sms_capable` (Twilio 30006, Vonage 14, MessageBird 10, 11 and 21, Sinch 152) and `sender_side` (such as Twilio 30007, 30022, 30032 and 30034, Vonage 6, 10, 11 and 39, MessageBird 104 and 110, Sinch 402 and 412). A code that says the recipient opted out — Twilio 21610, Vonage 9 and 1240, MessageBird 103 or the status reason `opted out` — is not recorded as a failure at all but as an `opted_out` outcome, which puts the number on your suppression list with reason `opted_out` and never scores it; Sinch publishes no opt-out code. The tables are a best-effort reading of each provider's documentation; a code none of them lists leaves the failure unclassified, which counts exactly as every failure did before classes existed.

        Throttled at 120 requests per minute per IP. Outcomes recorded here behave exactly like ones reported through `POST /api/v1/phone/feedback`: they feed the account's own later lookups and its suppression list, and are never billed. A receipt dated in the future is recorded as now.
      security: []
      x-spaw-auth: feedback-key
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNKNOWN_FEEDBACK_PROVIDER
        - INVALID_FEEDBACK_KEY
        - RATE_LIMITED
      parameters:
        -
          name: provider
          in: path
          required: true
          schema:
            type: string
            enum:
              - twilio
              - vonage
              - messagebird
              - sinch
          example: twilio
          description: "Which provider's payload format to expect."
        -
          name: feedbackKey
          in: path
          required: true
          schema:
            type: string
          example: fb_…
          description: "The account's feedback key, which starts with `fb_`."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: "The provider's native callback payload, as JSON or form fields."
              additionalProperties: true
            example:
              MessageSid: SM0f2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d
              To: '+447911012345'
              MessageStatus: undelivered
              ErrorCode: '30003'
      responses:
        200:
          description: 'The outcomes the payload carried were recorded.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      recorded:
                        type: integer
                      skipped:
                        type: integer
              example:
                success: true
                data:
                  recorded: 1
                  skipped: 0
        401:
          description: 'The feedback key does not exist or was rotated.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: INVALID_FEEDBACK_KEY
                  message: 'That feedback key does not exist or was rotated.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        404:
          description: 'The provider path is not one of twilio, vonage, messagebird, or sinch.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: UNKNOWN_FEEDBACK_PROVIDER
                  message: 'Supported providers: twilio, vonage, messagebird, sinch.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/phone/feedback/twilio/fb_… \
              -H "Content-Type: application/json" \
              -d '{
              "MessageSid": "SM0f2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
              "To": "+447911012345",
              "MessageStatus": "undelivered",
              "ErrorCode": "30003"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/feedback/twilio/fb_…', {
              method: 'POST',
              headers: {
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "MessageSid": "SM0f2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
                "To": "+447911012345",
                "MessageStatus": "undelivered",
                "ErrorCode": "30003"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/phone/feedback/twilio/fb_…',
                json={
                    'MessageSid': 'SM0f2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d',
                    'To': '+447911012345',
                    'MessageStatus': 'undelivered',
                    'ErrorCode': '30003'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/feedback/twilio/fb_…');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'MessageSid' => 'SM0f2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d',
                    'To' => '+447911012345',
                    'MessageStatus' => 'undelivered',
                    'ErrorCode' => '30003'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
  /api/v1/email/policy:
    get:
      operationId: get-email-policy
      tags:
        - Email
      summary: "Read the account's sending policy"
      description: |
        The rules behind `ok_to_send` on every email lookup this account runs: the signals that block, the risk-score ceiling and the mailbox-confidence floor. The policy belongs to the account, so every member reads and changes the same one. An undeliverable verdict is always blocked whatever the policy says.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - RATE_LIMITED
      responses:
        200:
          description: 'The policy in force.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/SendingPolicy'
              example:
                success: true
                data:
                  max_risk_score: 60
                  block_signals:
                    - disposable
                    - likely_typo
                    - parked_domain
                    - mx_blocklisted
                    - no_reply
                  min_mailbox_confidence: null
                  is_default: true
                  updated_at: null
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/email/policy \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/policy', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/email/policy',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/policy');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.get_email_policy()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.getEmailPolicy();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->getEmailPolicy();
    put:
      operationId: update-email-policy
      tags:
        - Email
      summary: 'Change one or more rules of the sending policy'
      description: |
        Names only the rules that change; the rest stay as they were, and the change applies to the account's next lookup. `max_risk_score: null` ignores the score, `block_signals: []` blocks on no signal. A body naming none of the three rules is refused with a 422 keyed `policy`, because that is the shape a misspelled key takes.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - RATE_LIMITED
        - VALIDATION_FAILED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                max_risk_score:
                  type:
                    - integer
                    - 'null'
                  minimum: 0
                  maximum: 100
                  description: 'Block when risk_score is above this; null ignores the score.'
                block_signals:
                  type:
                    - array
                    - 'null'
                  maxItems: 21
                  uniqueItems: true
                  items:
                    type: string
                    enum:
                      - disposable
                      - parked_domain
                      - likely_typo
                      - mx_blocklisted
                      - implicit_mx
                      - role
                      - catch_all
                      - young_domain
                      - recycled_mailbox
                      - mailbox_full
                      - gibberish
                      - generated
                      - no_reply
                      - widely_seen
                      - catch_all_gateway
                      - new_domain
                      - no_spf
                      - unverified
                      - expiring_domain
                      - alias
                      - relay
                  description: 'Signals that block whenever they fire, whatever the score.'
                min_mailbox_confidence:
                  type:
                    - integer
                    - 'null'
                  minimum: 0
                  maximum: 100
                  description: 'Block an unconfirmed mailbox whose mailbox_confidence is below this; null ignores it.'
            example:
              max_risk_score: 40
              block_signals:
                - disposable
                - likely_typo
                - parked_domain
                - mx_blocklisted
                - no_reply
                - catch_all
      responses:
        200:
          description: 'The policy now in force.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/SendingPolicy'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X PUT https://spaw.co/api/v1/email/policy \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "max_risk_score": 40,
              "block_signals": [
                "disposable",
                "likely_typo",
                "parked_domain",
                "mx_blocklisted",
                "no_reply",
                "catch_all"
              ]
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/policy', {
              method: 'PUT',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "max_risk_score": 40,
                "block_signals": [
                  "disposable",
                  "likely_typo",
                  "parked_domain",
                  "mx_blocklisted",
                  "no_reply",
                  "catch_all"
                ]
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.put(
                'https://spaw.co/api/v1/email/policy',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'max_risk_score': 40,
                    'block_signals': [
                        'disposable',
                        'likely_typo',
                        'parked_domain',
                        'mx_blocklisted',
                        'no_reply',
                        'catch_all'
                    ]
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/policy');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'PUT',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'max_risk_score' => 40,
                    'block_signals' => [
                        'disposable',
                        'likely_typo',
                        'parked_domain',
                        'mx_blocklisted',
                        'no_reply',
                        'catch_all'
                    ]
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.update_email_policy(max_risk_score=40, block_signals=[
                'disposable',
                'likely_typo',
                'parked_domain',
                'mx_blocklisted',
                'no_reply',
                'catch_all'
            ])
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.updateEmailPolicy({
                maxRiskScore: 40,
                blockSignals: [
                    'disposable',
                    'likely_typo',
                    'parked_domain',
                    'mx_blocklisted',
                    'no_reply',
                    'catch_all'
                ]
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->updateEmailPolicy([
                'max_risk_score' => 40,
                'block_signals' => [
                    'disposable',
                    'likely_typo',
                    'parked_domain',
                    'mx_blocklisted',
                    'no_reply',
                    'catch_all'
                ]
            ]);
    delete:
      operationId: reset-email-policy
      tags:
        - Email
      summary: 'Restore the default sending policy'
      description: 'Puts the published defaults back — block above 60 and on disposable, typo-squat, parked, blocklisted and no-reply — and forgets when the policy was last changed.'
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - RATE_LIMITED
      responses:
        200:
          description: 'The defaults, in force again.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/SendingPolicy'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X DELETE https://spaw.co/api/v1/email/policy \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/policy', {
              method: 'DELETE',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.delete(
                'https://spaw.co/api/v1/email/policy',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/policy');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'DELETE',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.reset_email_policy()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.resetEmailPolicy();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->resetEmailPolicy();
  /api/v1/email/domain-monitors:
    get:
      operationId: list-domain-monitors
      tags:
        - Email
      summary: "List the account's domain monitors"
      description: |
        The saved domain lists this account re-checks on a schedule, newest first, 100 per page. Each monitor carries the summary of its last run: how many domains were ok, flagged (parked, mail hosts on a threat blocklist, a registration ending within 30 days, no SPF, no DMARC, or a provider, SPF, DMARC, DKIM or MTA-STS change since the previous run), dead (no usable mail servers) or invalid (not a hostname), how many changed, how many newly need attention, the credits the run spent, and `stopped_reason` when it did not finish cleanly: `insufficient_credits` or `key_spend_cap` where the run stopped part-way, `run_failed` where it died outright, and `run_failed_retrying` where it died and the schedule has been pulled in to check again — `retry_at` in the same block says when that becomes due, and the hourly sweep picks it up at or after that, so between 45 minutes and about an hour and three quarters later. A summary carrying a `stopped_reason` of either failure kind has NO counts in it at all: the run produced none, so read a missing count as unknown rather than as zero. The domains and their answers are on the single-monitor endpoint.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - RATE_LIMITED
      parameters:
        -
          name: page
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            default: 1
          example: 1
          description: 'The page to return.'
      responses:
        200:
          description: 'One page of monitors.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitors:
                        type: array
                        items:
                          $ref: '#/components/schemas/DomainMonitor'
                  meta:
                    type: object
                    properties:
                      page:
                        type: integer
                      per_page:
                        type: integer
                      total:
                        type: integer
                      retention_days:
                        type: integer
                        description: 'How long a list nobody runs is kept before it is deleted.'
              example:
                success: true
                data:
                  monitors:
                    -
                      id: 4
                      name: 'Sending domains'
                      cadence: daily
                      domain_count: 2
                      next_run_at: '2026-09-14T08:00:00+00:00'
                      last_run_at: '2026-09-13T08:00:03+00:00'
                      last_summary:
                        total: 2
                        ok: 1
                        flagged: 1
                        dead: 0
                        invalid: 0
                        changed: 1
                        newly_flagged: 1
                        credits_used: 0
                        stopped_reason: null
                      webhook_url: null
                      webhook_events: changes
                      webhook_status: null
                      webhook_detail: null
                      prunes_after_days: 365
                      created_at: '2026-09-01T08:00:00+00:00'
                meta:
                  page: 1
                  per_page: 100
                  total: 1
                  retention_days: 365
        401:
          $ref: '#/components/responses/Unauthenticated'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/email/domain-monitors \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/domain-monitors', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/email/domain-monitors',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/domain-monitors');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.list_domain_monitors()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.listDomainMonitors();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->listDomainMonitors();
    post:
      operationId: create-domain-monitor
      tags:
        - Email
      summary: 'Monitor a list of domains on a schedule'
      description: |
        Saves up to 500 domains — your own sending domains, or the domains your list is full of — and re-checks them every day, week or month through the domain endpoint. Domains are lowercased, converted to punycode and deduplicated. The first run starts right away and only sets the baseline; from the next run on, every domain that changes mail provider, loses its SPF, DMARC, DKIM keys or MTA-STS policy, is parked, lands its mail hosts on a threat blocklist, has a registration ending within 30 days, or stops accepting mail is reported to the account's email with the reason.

        Answers `201` with the monitor. The baseline run is queued, not finished: poll the monitor for `last_run_at` and `last_summary`.

        With a `webhook_url`, a finished run is POSTed there as `{ "event": "domain_monitor.run", "monitor": { "id", "name", "cadence", "last_run_at", "next_run_at" }, "summary": { … the same block as `last_summary` … }, "changes": [ … ] }`, where `changes` names the domains the alert email names. The `X-Spaw-Signature-V2` header carries `t={unix seconds},v2={lowercase hex}`, where the hex is the HMAC-SHA256 of `v2:{t}:{raw body}` keyed with `webhook_secret`, which this response returns once; the original `X-Spaw-Signature` is sent beside it until 1 March 2027. Connection errors and 5xx answers are retried twice, and the outcome is readable afterwards as `webhook_status` and `webhook_detail`. A URL that resolves onto a private network is never posted to.
      x-spaw-auth: bearer
      x-spaw-billing: 'Creation is free. Each run bills every domain like one domain lookup: 1 credit when the domain accepts mail; dead and invalid domains and 7-day repeats are free, so a domain is billed at most once a week whatever the cadence.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
                - domains
                - cadence
              properties:
                name:
                  type: string
                  maxLength: 100
                  description: 'A label for the alerts.'
                domains:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type: string
                  description: '1 to 500 domain names. Spellings of one domain (case, a trailing dot, an internationalised form) count once; anything that is not a hostname answers 422 naming the entry.'
                cadence:
                  type: string
                  enum:
                    - daily
                    - weekly
                    - monthly
                  description: 'How often the list is re-checked.'
                webhook_url:
                  type:
                    - string
                    - 'null'
                  maxLength: 2048
                  format: uri
                  description: 'An https URL every reported run is POSTed to. The signing secret is returned once, in this response, and never again.'
                webhook_events:
                  type: string
                  enum:
                    - changes
                    - every_run
                  default: changes
                  description: 'When to post. `changes` posts only when the run found something new or stopped early; `every_run` posts after every run.'
            example:
              name: 'Sending domains'
              domains:
                - acme.com
                - news.acme.com
              cadence: daily
      responses:
        201:
          description: 'The monitor was saved and its baseline run queued.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        $ref: '#/components/schemas/CreatedDomainMonitor'
              example:
                success: true
                data:
                  monitor:
                    id: 4
                    name: 'Sending domains'
                    cadence: daily
                    domain_count: 2
                    next_run_at: '2026-09-14T10:12:44+00:00'
                    last_run_at: null
                    last_summary: null
                    webhook_url: null
                    webhook_events: changes
                    webhook_status: null
                    webhook_detail: null
                    prunes_after_days: 365
                    created_at: '2026-09-13T10:12:44+00:00'
                    webhook_secret: null
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/email/domain-monitors \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "name": "Sending domains",
              "domains": [
                "acme.com",
                "news.acme.com"
              ],
              "cadence": "daily"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/domain-monitors', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "name": "Sending domains",
                "domains": [
                  "acme.com",
                  "news.acme.com"
                ],
                "cadence": "daily"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/email/domain-monitors',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'name': 'Sending domains',
                    'domains': [
                        'acme.com',
                        'news.acme.com'
                    ],
                    'cadence': 'daily'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/domain-monitors');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'name' => 'Sending domains',
                    'domains' => [
                        'acme.com',
                        'news.acme.com'
                    ],
                    'cadence' => 'daily'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.create_domain_monitor(name='Sending domains', domains=[
                'acme.com',
                'news.acme.com'
            ], cadence='daily')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.createDomainMonitor({
                name: 'Sending domains',
                domains: [
                    'acme.com',
                    'news.acme.com'
                ],
                cadence: 'daily'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->createDomainMonitor(name: 'Sending domains', domains: [
                'acme.com',
                'news.acme.com'
            ], cadence: 'daily');
  '/api/v1/email/domain-monitors/{monitorId}':
    get:
      operationId: get-domain-monitor
      tags:
        - Email
      summary: 'Read a domain monitor and its last results'
      description: |
        The monitor plus `results`: for every domain from the last run, its status (`ok`, `flagged`, `dead` or `invalid`), the flags that fired (`parked`, `blocklisted`, `expiring`, `no_spf`, `no_dmarc`, and the ones that compare with the previous run: `provider_changed`, `spf_changed`, `dmarc_changed`, `dkim_lost`, `mta_sts_lost`), whether it changed since the previous run, and the facts the next run compares against; keyed by the domain in punycode, or null until the first run has finished. A monitor that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
          example: 4
          description: "The monitor's id from the list endpoint."
      responses:
        200:
          description: 'The monitor.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        allOf:
                          -
                            $ref: '#/components/schemas/DomainMonitor'
                          -
                            type: object
                            properties:
                              results:
                                type:
                                  - object
                                  - 'null'
                                additionalProperties:
                                  $ref: '#/components/schemas/DomainMonitorResult'
                                description: "The last run's answer per domain, keyed by the domain; null until the first run finishes."
              example:
                success: true
                data:
                  monitor:
                    id: 4
                    name: 'Sending domains'
                    cadence: daily
                    domain_count: 2
                    next_run_at: '2026-09-14T08:00:00+00:00'
                    last_run_at: '2026-09-13T08:00:03+00:00'
                    last_summary:
                      total: 2
                      ok: 1
                      flagged: 1
                      dead: 0
                      invalid: 0
                      changed: 1
                      newly_flagged: 1
                      credits_used: 0
                      stopped_reason: null
                    webhook_url: null
                    webhook_events: changes
                    webhook_status: null
                    webhook_detail: null
                    prunes_after_days: 365
                    created_at: '2026-09-01T08:00:00+00:00'
                    results:
                      acme.com:
                        status: flagged
                        flags:
                          - no_dmarc
                          - provider_changed
                          - dmarc_changed
                        changed: true
                        valid: true
                        mx_found: true
                        mx_provider: other
                        mx_provider_type: self_hosted
                        has_spf: true
                        spf_all: '-all'
                        dmarc_policy: null
                        dkim_selectors: []
                        mta_sts: true
                        parked: false
                        mx_blocklisted: false
                        domain_expires_at: '2027-03-12'
                      news.acme.com:
                        status: ok
                        flags: []
                        changed: false
                        valid: true
                        mx_found: true
                        mx_provider: google
                        mx_provider_type: mailbox_provider
                        has_spf: true
                        spf_all: '-all'
                        dmarc_policy: reject
                        dkim_selectors:
                          - google
                        mta_sts: true
                        parked: false
                        mx_blocklisted: false
                        domain_expires_at: '2027-03-12'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/email/domain-monitors/4 \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/domain-monitors/4', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/email/domain-monitors/4',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/domain-monitors/4');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.get_domain_monitor(4)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.getDomainMonitor(4);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->getDomainMonitor(4);
    patch:
      operationId: update-domain-monitor
      tags:
        - Email
      summary: 'Change a domain monitor'
      description: |
        Changes a saved monitor in place: rename it, move it to another cadence, replace the domains, or point it at a webhook. Send only the fields you are changing; a body that names none of them answers `422` rather than quietly changing nothing.

        Replacing `domains` replaces the list. What the last run recorded about the domains that stayed is kept, so their next run still compares against the answer they already had; what it recorded about the ones that are gone is deleted with them; a new entry starts with no baseline. A rename never touches a stored answer. Changing `cadence` neither skips a run nor starts a second one. Nothing is queued by this call; `POST .../run` is there to have it now.

        Sending a `webhook_url` that differs from the one stored mints a new signing secret and returns it once, here; the URL it already has keeps the secret, and `null` removes the webhook. A monitor that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: 'Free. The change applies to the next run.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - VALIDATION_FAILED
        - RATE_LIMITED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
          example: 4
          description: "The monitor's id from the list endpoint."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                name:
                  type: string
                  maxLength: 100
                domains:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type: string
                cadence:
                  type: string
                  enum:
                    - daily
                    - weekly
                    - monthly
                webhook_url:
                  type:
                    - string
                    - 'null'
                  maxLength: 2048
                  format: uri
                webhook_events:
                  type: string
                  enum:
                    - changes
                    - every_run
            example:
              domains:
                - acme.com
                - shop.acme.com
              cadence: weekly
      responses:
        200:
          description: 'The monitor as it now stands.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        allOf:
                          -
                            $ref: '#/components/schemas/DomainMonitor'
                          -
                            type: object
                            properties:
                              webhook_secret:
                                type: string
                                description: 'Present only when this call pointed the monitor at a URL it was not already posting to. Shown once.'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X PATCH https://spaw.co/api/v1/email/domain-monitors/4 \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "domains": [
                "acme.com",
                "shop.acme.com"
              ],
              "cadence": "weekly"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/domain-monitors/4', {
              method: 'PATCH',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "domains": [
                  "acme.com",
                  "shop.acme.com"
                ],
                "cadence": "weekly"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.patch(
                'https://spaw.co/api/v1/email/domain-monitors/4',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'domains': [
                        'acme.com',
                        'shop.acme.com'
                    ],
                    'cadence': 'weekly'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/domain-monitors/4');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'PATCH',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'domains' => [
                        'acme.com',
                        'shop.acme.com'
                    ],
                    'cadence' => 'weekly'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.update_domain_monitor(4, domains=[
                'acme.com',
                'shop.acme.com'
            ], cadence='weekly')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.updateDomainMonitor(4, {
                domains: [
                    'acme.com',
                    'shop.acme.com'
                ],
                cadence: 'weekly'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->updateDomainMonitor(4, [
                'domains' => [
                    'acme.com',
                    'shop.acme.com'
                ],
                'cadence' => 'weekly'
            ]);
    delete:
      operationId: delete-domain-monitor
      tags:
        - Email
      summary: 'Stop monitoring a domain list'
      description: |
        Removes the monitor and its stored results; nothing further is scheduled. A run that is already queued finishes on its own and bills as usual. A monitor that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
          example: 4
          description: "The monitor's id from the list endpoint."
      responses:
        200:
          description: 'The monitor was removed.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
              example:
                success: true
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X DELETE https://spaw.co/api/v1/email/domain-monitors/4 \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/domain-monitors/4', {
              method: 'DELETE',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.delete(
                'https://spaw.co/api/v1/email/domain-monitors/4',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/domain-monitors/4');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'DELETE',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.delete_domain_monitor(4)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.deleteDomainMonitor(4);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->deleteDomainMonitor(4);
  '/api/v1/email/domain-monitors/{monitorId}/run':
    post:
      operationId: run-domain-monitor
      tags:
        - Email
      summary: 'Re-check a monitored domain list now'
      description: |
        Queues a run right away instead of waiting for the schedule; when it finishes, the next scheduled run is set one cadence from then. Answers `202` with the monitor as it stands before the run; poll it for the new `last_run_at`, `last_summary` and `results`. A monitor that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: 'The run bills every domain like one domain lookup; dead and invalid domains and 7-day repeats are free.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
          example: 4
          description: "The monitor's id from the list endpoint."
      responses:
        202:
          description: 'The run was queued.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        $ref: '#/components/schemas/DomainMonitor'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/email/domain-monitors/4/run \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/domain-monitors/4/run', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/email/domain-monitors/4/run',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/domain-monitors/4/run');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.run_domain_monitor(4)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.runDomainMonitor(4);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->runDomainMonitor(4);
  /api/v1/email/history:
    get:
      operationId: list-history
      tags:
        - History
      summary: "List the account's verification history"
      description: |
        Every email lookup the account ran through any channel — the API, the dashboard, the form widget, batch and bulk runs, monitors, the MCP server — newest first, 100 per page, for the retention window (`meta.retention_days`, 30 days by default). Each row records the verdict, the reason, the risk score, whether the mailbox handshake answered, what the lookup cost, whether it was a 7-day repeat, and the channel that ran it as `via`.

        Rows are written after billing, so a refused lookup never appears here, and test addresses are never logged. Filters combine. An unknown verdict or an unparseable date answers `422`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      parameters:
        -
          name: page
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            default: 1
          example: 1
          description: 'The page to return.'
        -
          name: verdict
          in: query
          required: false
          schema:
            type: string
            enum:
              - deliverable
              - risky
              - undeliverable
          example: undeliverable
          description: 'Only lookups with this verdict.'
        -
          name: email
          in: query
          required: false
          schema:
            type: string
            maxLength: 254
          example: mia@acme.com
          description: 'Only lookups of this exact address, compared case-insensitively against the address as it was checked.'
        -
          name: since
          in: query
          required: false
          schema:
            type: string
            format: date-time
          example: '2026-09-01'
          description: 'Only lookups on or after this date or timestamp (ISO 8601).'
        -
          name: until
          in: query
          required: false
          schema:
            type: string
            format: date-time
          example: '2026-09-03'
          description: 'Only lookups on or before this timestamp; a bare date covers the whole day.'
        -
          name: via
          in: query
          required: false
          schema:
            type: string
            maxLength: 120
          example: dashboard
          description: 'Only lookups from this channel, matched exactly: `api · {key name}`, `dashboard`, `widget · {key name}`, `bulk run #{id}`, `monitor · {name}`, or `mcp · {key name}`.'
      responses:
        200:
          description: 'One page of history rows.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      lookups:
                        type: array
                        items:
                          $ref: '#/components/schemas/HistoryRow'
                  meta:
                    type: object
                    properties:
                      page:
                        type: integer
                      per_page:
                        type: integer
                      total:
                        type: integer
                      retention_days:
                        type: integer
                        description: 'How many days of history are kept.'
              example:
                success: true
                data:
                  lookups:
                    -
                      id: 9310
                      email: mia@acme.com
                      deliverable: deliverable
                      reason: null
                      risk_score: 0
                      mx_provider: google
                      smtp_checked: true
                      credits_used: 1
                      cache_hit: false
                      via: 'api · Production key'
                      created_at: '2026-09-03T10:12:44+00:00'
                    -
                      id: 9309
                      email: info@example.org
                      deliverable: risky
                      reason: role
                      risk_score: 30
                      mx_provider: other
                      smtp_checked: true
                      credits_used: 0
                      cache_hit: true
                      via: dashboard
                      created_at: '2026-09-03T09:58:02+00:00'
                meta:
                  page: 1
                  per_page: 100
                  total: 2
                  retention_days: 30
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/email/history \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/email/history', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/email/history',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/email/history');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.list_history()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.listHistory();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->listHistory();
  /api/v1/account:
    get:
      operationId: get-account
      tags:
        - Account
      summary: 'Read the account and credit balance'
      description: |
        The account behind the presented key: its name and email, the plan (`free`, or the key of the plan while it is paid up, monthly or yearly: `starter`, `growth`, `scale`, `volume`, `pro`, `business` or `enterprise`), the live credit balance, and the monthly free grant. Useful for dashboards and pre-flight checks before a big run. Reading the balance applies the month's free grant if it has not been applied yet.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - RATE_LIMITED
      responses:
        200:
          description: 'The account.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/Account'
              example:
                success: true
                data:
                  name: 'Mia Kowalski'
                  email: mia@acme.com
                  plan: free
                  credits:
                    balance: 99
                    monthly_grant: 100
        401:
          $ref: '#/components/responses/Unauthenticated'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/account \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/account', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/account',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/account');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.account()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.account();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->account();
  '/api/v1/account/requests/{requestId}':
    get:
      operationId: get-request
      tags:
        - Account
      summary: 'Look up one request by its id'
      description: |
        Every response carries an `X-Request-Id` header and repeats the same id in its `meta` or `error` block. This reads that id back: the endpoint that was called, the status and typed `error_code` it answered with, the credits it spent and how long it took. It is what a script that caught a failure can call to find out what happened, and what to quote to support.

        The row carries no lookup input. The endpoint is named by its route, never by the path it was called on, so this can never read back the address, number or identifier a call was about. Rows are kept for `meta.retention_days` and then deleted; an id older than that, or belonging to another account, answers `404 NOT_FOUND`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: requestId
          in: path
          required: true
          schema:
            type: string
          description: 'The id from the X-Request-Id header, e.g. req_01m1kgdm4xngzmbmff68g94w0c.'
      responses:
        200:
          description: 'The request as it was recorded.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      request_id:
                        type: string
                      route:
                        type: string
                        description: "The route's name, e.g. api.v1.email."
                      method:
                        type: string
                      status:
                        type: integer
                      error_code:
                        type:
                          - string
                          - 'null'
                        description: 'The typed code from the error envelope, null when the call succeeded.'
                      credits_used:
                        type: integer
                      duration_ms:
                        type: integer
                      via:
                        type:
                          - string
                          - 'null'
                        description: 'The key or channel it came through.'
                      occurred_at:
                        type: string
                        format: date-time
                  meta:
                    type: object
                    properties:
                      retention_days:
                        type: integer
                      request_id:
                        type:
                          - string
                          - 'null'
              example:
                success: true
                data:
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
                  route: api.v1.email
                  method: POST
                  status: 402
                  error_code: INSUFFICIENT_CREDITS
                  credits_used: 0
                  duration_ms: 8
                  via: 'api · Production'
                  occurred_at: '2026-09-09T09:41:02+00:00'
                meta:
                  retention_days: 30
                  request_id: req_01m22gaxhnj60f71ez5tjcdcvy
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/account/requests/{requestId} \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/account/requests/{requestId}', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/account/requests/{requestId}',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/account/requests/{requestId}');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.request_log(request_id)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.requestLog(requestId);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->requestLog($requestId);
  /api/v1/account/ledger:
    get:
      operationId: list-ledger
      tags:
        - Account
      summary: 'Read the credit ledger'
      description: |
        Every credit movement on the account, newest first: lookups debited, monthly grants, purchased packs and plan deposits. Each row carries the signed `delta`, the `balance_after` it left, the `reason`, an optional `reference` (a Stripe session or invoice id for purchases), and `via`, the label of the key, widget, bulk run or monitor that spent it. Each row spent through a secret key also carries that key's `api_token_id`, which is what tells two keys of the same name apart and what a rename does not move; it is null for the dashboard, for bulk runs and monitors, and for grants and purchases. An `ip_lookup` row is one credit paying for five answered IP lookups: the lookup that opens a block writes it, and the four after it write nothing (rows written before IP lookups were billed in blocks stand for one lookup each). Filter with `since` and `until` (ISO 8601 dates), `reason`, `via` and `api_token_id`; page with `page` and `per_page` (up to 200). The dashboard's Usage page offers the same rows as a CSV download.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      parameters:
        -
          name: since
          in: query
          required: false
          schema:
            type: string
            format: date
          description: 'Only rows on or after this date.'
        -
          name: until
          in: query
          required: false
          schema:
            type: string
            format: date
          description: 'Only rows on or before this date.'
        -
          name: reason
          in: query
          required: false
          schema:
            type: string
          description: 'Only rows with this reason, e.g. email_lookup, ip_lookup, monthly_grant, purchase, subscription, plan_credits_expired.'
        -
          name: via
          in: query
          required: false
          schema:
            type: string
          description: 'Only rows spent through this label, e.g. "api · Production".'
        -
          name: api_token_id
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
          description: 'Only rows spent by this secret key. An id belonging to another account matches no rows, because the ledger is scoped to the caller first.'
        -
          name: page
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
        -
          name: per_page
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        200:
          description: 'One page of ledger rows.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/LedgerEntry'
                  meta:
                    type: object
                    properties:
                      page:
                        type: integer
                      per_page:
                        type: integer
                      total:
                        type: integer
                      request_id:
                        type:
                          - string
                          - 'null'
              example:
                success: true
                data:
                  -
                    id: 9812
                    delta: -1
                    balance_after: 99
                    lookups: 5
                    reason: ip_lookup
                    reference: null
                    via: 'api · Production'
                    api_token_id: 41
                    created_at: '2026-09-04T10:12:44+00:00'
                  -
                    id: 9801
                    delta: 100
                    balance_after: 100
                    lookups: 0
                    reason: monthly_grant
                    reference: 2026-09
                    via: null
                    api_token_id: null
                    created_at: '2026-09-01T00:00:03+00:00'
                meta:
                  page: 1
                  per_page: 50
                  total: 2
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/account/ledger \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/account/ledger', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/account/ledger',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/account/ledger');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.ledger()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.ledger();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->ledger();
  /api/v1/status/data:
    get:
      operationId: get-data-status
      tags:
        - Status
      summary: 'Health, latency and data freshness'
      description: |
        The facts behind the public status page, without authentication: the state of each component behind an answer (the database, IP geolocation, the mailbox handshake, the live carrier check, and the server-side renderer behind the website), the sync date, freshness and range counts of every dataset behind the lookups, measured API latency per endpoint group over the last hour and the last 24 hours, and ninety days of measured availability rolled up a day at a time.

        `ok` is false when any component is degraded; a component that was never switched on for this deployment reads `not_configured` and does not make it false. A degraded component that is holding off says when it will be retried in `retry_in_seconds`. Percentiles are computed over the requests that succeeded — a refusal that never reached a provider is not evidence the API is fast — and `success_rate` reports the share that succeeded, over `sample` requests of which `answered` were answered. `error_rate` is the share that failed on this side — a 402, 429 or 422 is an answer about the request, not a fault here, so only `error_rate` is a reason to worry. A window with fewer than 20 requests is not published at all, and the percentiles are withheld unless at least 20 of them were answered. The weekly quality measurements published on the status page — the delivery outcomes customers reported back against the email verdicts we gave, and the business-register round trip — are page copy and are deliberately not in this payload: they are measured once a week and dated on the page, and a monitor polling for one would be polling for last Sunday. Point a monitor at it, or read it before a big run to know how fresh the feeds are. Throttled at 60 requests per minute per IP.
      security: []
      x-spaw-auth: none
      x-spaw-billing: Free.
      x-spaw-errors:
        - RATE_LIMITED
      responses:
        200:
          description: 'The current status.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/DataStatus'
              example:
                success: true
                data:
                  ok: false
                  checked_at: '2026-09-04T18:00:00+00:00'
                  components:
                    -
                      component: database
                      name: Database
                      state: operational
                      detail: 'Queries are answering.'
                      retry_in_seconds: null
                    -
                      component: ip-geolocation
                      name: 'IP geolocation'
                      state: operational
                      detail: 'The geolocation database is installed.'
                      retry_in_seconds: null
                    -
                      component: mailbox-handshake
                      name: 'Mailbox handshake'
                      state: degraded
                      detail: 'Probes are paused after repeated upstream failures, so addresses answer unverified instead of waiting out a timeout. The next attempt is in 240 seconds.'
                      retry_in_seconds: 240
                    -
                      component: live-carrier-check
                      name: 'Live carrier check'
                      state: not_configured
                      detail: 'Not switched on for this deployment: phone verdicts come from the numbering data alone.'
                      retry_in_seconds: null
                    -
                      component: ssr
                      name: 'Server-side rendering'
                      state: operational
                      detail: 'Pages are rendered on the server before they are sent.'
                      retry_in_seconds: null
                  datasets:
                    -
                      dataset: dbip-city-lite
                      version: '2026-08-01'
                      installed: true
                      feeds: null
                      freshness: current
                      age_days: 34
                    -
                      dataset: datacenter-ranges
                      version: '2026-09-04'
                      installed: true
                      feeds: 12
                      freshness: current
                      age_days: 0
                    -
                      dataset: libphonenumber
                      version: 9.0.37
                      installed: true
                      feeds: null
                      freshness: not_dated
                      age_days: null
                  latency:
                    -
                      group: ip
                      window: 1h
                      p50_ms: 2
                      p95_ms: 6
                      success_rate: 100
                      error_rate: 0
                      sample: 812
                      answered: 812
                    -
                      group: email
                      window: 1h
                      p50_ms: 410
                      p95_ms: 2100
                      success_rate: 98.42
                      error_rate: 0.08
                      sample: 1204
                      answered: 1185
                  availability:
                    days: 90
                    since: '2026-06-13'
                    until: '2026-09-10'
                    measured_days: 62
                    days_without_requests: 28
                    requests: 184320
                    answered: 184102
                    failed: 218
                    availability: 99.88
                    history:
                      -
                        date: '2026-09-09'
                        requests: 3140
                        answered: 3138
                        failed: 2
                        availability: 99.94
                        state: operational
                        complete: true
                      -
                        date: '2026-09-10'
                        requests: 812
                        answered: 812
                        failed: 0
                        availability: 100
                        state: operational
                        complete: false
                      -
                        date: '2026-06-13'
                        requests: 0
                        answered: 0
                        failed: 0
                        availability: null
                        state: no_requests
                        complete: true
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: 'curl https://spaw.co/api/v1/status/data'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/status/data', {
              method: 'GET',
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/status/data',
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/status/data');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.data_status()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.dataStatus();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->dataStatus();
  /api/v1/phone:
    post:
      operationId: validate-phone
      tags:
        - Phone
      summary: 'Validate and enrich a phone number'
      description: |
        Parses a number with libphonenumber metadata and answers whether it is valid for its region, its E.164, national, international and `tel:` forms, the country and its facts (name, calling code, continent, EU membership, currency), the line type, the carrier, the geographic description and the time zones. Pass `country` (an ISO 3166-1 alpha-2 code) to parse numbers written without a country calling code; a secret key created with a default country supplies it whenever a request names none.

        Where a regulator publishes its number-block allocations (NANPA for the United States and its territories, the CNAC for Canada, Ofcom for the United Kingdom and the Crown dependencies), the lookup also reads the block the number sits in: `block_status` says whether the block is allocated, and a geographic number in a block nobody holds answers `valid: false` with reason `unassigned_block`. The block holder names the `carrier` (`carrier_source: block_allocation`) and, for the North American plan's ambiguous `fixed_line_or_mobile`, settles the `line_type` when the holder is a wireless carrier (`mobile`) or a virtual-number wholesaler (`voip`); `line_type_source` says where the answer came from. Neither is port-aware: both reflect who the numbers were allocated to, not who serves them today.

        Three more free signals: `is_fictional` for ranges reserved for fiction (the NANP 555-01XX block; the drama ranges and numbers of Ofcom, ARCEP, ACMA and ComReg), `is_disposable` for numbers published by SMS-receiving websites, and `is_virtual` for blocks held by virtual-number wholesalers. Emergency numbers and short codes answer `valid: false` with reason `emergency_number` or `short_number`. The `risk_score` sums the weights of the signals in `risk_signals` (fictional 80, disposable 70, disposable_stale 40 for a listing that is no longer fresh (last seen more than a year ago, or more than 90 days ago once every list dropped the number), reported_abuse 60 and reported_undelivered 50 from your own SMS outcome reports, reported_abuse_widely 40 when three or more accounts reported abuse for the number within 30 days, premium_rate 60 (in the UK, the 09 range), reserved_block 40, virtual 40, range_burst 40 when your account looked up several numbers in one 1,000-number range within the window, voip 30, pager 30, digit_pattern 30, numbers_per_client 30 when one client IP tried several numbers, disposable_range 30 for a number not listed itself in a 1,000-number range holding ten or more SMS-receiving listings that still score disposable, international_network 30, shared_cost 20 (including the UK 084 and 087 ranges, which Ofcom's plan classes as capped service numbers), reported_unwanted_calls 20 when consumers complained to the FTC about a North American caller three or more times on at least two days within 90 days; 60 and above is `high`, 30 and above `medium`), each with the dataset that produced it. Signals answer `null` when their dataset is not installed, never a guess.

        Every answer ends with `ok_to_send` and `blocked_by`: your account's SMS policy (`GET /api/v1/phone/policy`) applied to the answer — the countries and line types you send to, the signals that block, a ceiling on the score, whether a number your suppression list holds as opted out is blocked and, if you require it, a live check that reached the handset. The defaults block a number held as opted out, a score above 60, and the signals fictional, disposable, reported_abuse, reported_abuse_widely and premium_rate, so the fictional example +1 415 555 0142 answers `ok_to_send: false` with `blocked_by: fictional`. It is a fraud and deliverability decision, not consent and not a do-not-call scrub. `client_ip`, the address of the person behind the lookup, is counted for `client_velocity` and never looked up or billed; `call_window` ("09:00-20:00" in the number's local time) answers `in_call_window`, next to `local_time` and `utc_offsets`, which every answer carries: the zone is where the number was issued, not where the person is, and the answer is clock arithmetic, not compliance advice. An invalid answer to a number sent with a `country` may carry `did_you_mean`, the one valid reading of what was typed (a missing `+`, a foreign international prefix left in); it is never applied. `meta.opted_out` says your suppression list holds the number as opted out (see `POST /api/v1/phone/feedback`). The number is still answered and scored in full, and its score never changes; under the default SMS policy the answer is `ok_to_send: false` with `blocked_by: opted_out`. An OTP or transactional sender may switch `block_opted_out` off, because an opt-out from marketing does not always forbid a verification code and the person typing the number may be its owner asking for one again; honouring an opt-out stays your obligation.

        `hlr: true` asks for the live carrier-network check (reachable, ported, current carrier, roaming). The live carrier-network check (`hlr: true`) is not enabled on the service: `hlr_checked` answers false and nothing extra is charged. Once it is, a definitive answer is shared across customers for a day, so a handset is queried at most once a day, and the premium credits are charged only when the network answered.

        Eight test numbers in the Washington 555-01XX fiction block answer a fixed result each, cost nothing and leave no repeat marker, so integration tests never spend credits or depend on the day's data: +1 202 555 0100 (mobile), 0101 (landline), 0102 (virtual number), 0103 (disposable), 0104 (unassigned block), 0105 (fictional), 0106 (invalid) and 0113 (in a range crowded with SMS-receiving numbers). Their `sources` name the `test-numbers` dataset.

        The endpoint answers under the same envelope, billing and error codes as every other lookup; the phone intelligence guide at https://spaw.co/docs/phone-intelligence explains every field and where it comes from.
      x-spaw-auth: bearer
      x-spaw-billing: "1 credit for a fresh lookup that answers `valid` true; invalid numbers (including an unassigned block) and 7-day repeats are free. The base credit is charged once per number every 7 days whether or not the lookup asked for the live check, so a check after a plain lookup costs only the check and a plain lookup after a check is free. Asking for the live check (hlr) adds 4 credits only when it actually answered, so a fresh lookup with a check costs 5 in all. Once the account has paid for a check of the number, a repeated check within 7 days is free while the network's answer is held (a day); a repeat after that asks the network again and is charged the 4 credits, reserved up front. A check the account has not paid for in the last 7 days is charged the 4 credits whenever it answers, even from a held answer. Paying for the check alone does not restart the base credit's 7 days. A fictional number or an unassigned block is never checked, and never charged for the check."
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - INSUFFICIENT_CREDITS
        - RATE_LIMITED
        - SANDBOX_VALUE_NOT_ALLOWED
        - SANDBOX_UNAVAILABLE
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - phone
              properties:
                phone:
                  type: string
                  maxLength: 64
                  description: 'The number, in any common notation. Include the country calling code or pass `country`.'
                country:
                  type:
                    - string
                    - 'null'
                  description: "An ISO 3166-1 alpha-2 region code (`US`, `GB`, `PL`) used to parse numbers written without a calling code, and to recognise that region's emergency numbers and short codes."
                hlr:
                  type: boolean
                  default: false
                  description: 'Run the live carrier-network check. The live carrier-network check (`hlr: true`) is not enabled on the service: `hlr_checked` answers false and nothing extra is charged. Once it is, it reserves the premium credits up front and charges them only when the check answers. The base credit is shared with a plain lookup of the same number, so a check within 7 days of one costs only the premium.'
                client_ip:
                  type:
                    - string
                    - 'null'
                  format: ip
                  description: "The IP address of the person behind the lookup (your visitor, not your server), such as the address your signup form saw. Only counted: the answer's `client_velocity` is the distinct numbers your account looked up from that address (an IPv6 address by its /64) in the last 60 minutes, and five or more add `numbers_per_client` unless Spaw's own IP data places the address on a mobile carrier or a corporate proxy, or it is private or reserved. Never looked up and never billed."
                call_window:
                  type:
                    - string
                    - 'null'
                  pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]$'
                  example: '09:00-20:00'
                  description: 'Your calling window in the number''s local time, "HH:MM-HH:MM", from the start up to, not including, the end; a window may run past midnight ("21:00-08:00"). Answers `in_call_window`, true only when the local time in every zone the number can be in is inside it. The zone is where the number was issued, not where the person is, and the answer is clock arithmetic, not compliance advice.'
            example:
              phone: '+1 202 555 0100'
      responses:
        200:
          description: 'The parsed number; `valid: false` with a `reason` for numbers that do not parse, are not assignable, or sit in a block nobody holds.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/PhoneResult'
                  meta:
                    $ref: '#/components/schemas/PhoneMeta'
              example:
                success: true
                data:
                  valid: true
                  reason: null
                  e164: '+12025550100'
                  national: '(202) 555-0100'
                  international: '+1 202-555-0100'
                  rfc3966: 'tel:+1-202-555-0100'
                  country: US
                  country_name: 'United States'
                  calling_code: 1
                  continent: NA
                  is_eu: false
                  currency: USD
                  line_type: mobile
                  line_type_source: block_allocation
                  carrier: 'Spaw Test Wireless'
                  carrier_source: block_allocation
                  block_status: allocated
                  region: 'Washington D.C.'
                  is_geographic: true
                  timezones:
                    - America/New_York
                  extension: null
                  is_fictional: false
                  is_virtual: false
                  is_disposable: false
                  disposable_seen_at: null
                  risk_score: 0
                  risk_level: low
                  risk_signals: []
                  hlr_checked: false
                  reachable: null
                  ported: null
                  current_carrier: null
                  roaming: null
                  carrier_code: null
                  carrier_brand: null
                  block_pooled: null
                  block_assigned_at: null
                  region_level: area
                  local_time: '2026-09-24T09:30:00-04:00'
                  utc_offsets:
                    - '-04:00'
                  in_call_window: null
                  did_you_mean: null
                  range_seen_by_you: null
                  client_velocity: null
                  destination_new_for_you: null
                  complaints_90d: null
                  complaints_last_at: null
                  ok_to_send: true
                  blocked_by: null
                  sources:
                    -
                      dataset: test-numbers
                      version: bundled
                meta:
                  credits_used: 0
                  credits_remaining: 10
                  cache_hit: false
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
                  suppressed: false
                  opted_out: false
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/phone \
              -H "Authorization: Bearer sk_sandbox_spaw" \
              -H "Content-Type: application/json" \
              -d '{
              "phone": "+1 202 555 0100"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_sandbox_spaw',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "phone": "+1 202 555 0100"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/phone',
                headers={'Authorization': 'Bearer sk_sandbox_spaw'},
                json={
                    'phone': '+1 202 555 0100'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_sandbox_spaw', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'phone' => '+1 202 555 0100'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_sandbox_spaw')
            result = client.validate_phone('+1 202 555 0100')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_sandbox_spaw' });
            const result = await spaw.validatePhone('+1 202 555 0100');
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_sandbox_spaw');
            $result = $spaw->validatePhone('+1 202 555 0100');
  /api/v1/phone/batch:
    post:
      operationId: lookup-phone-batch
      tags:
        - Phone
      summary: 'Validate up to 1,000 numbers in one call'
      description: |
        Runs the single-number lookup for every item, in input order, under the exact single-lookup billing rules. Every answer comes from local metadata and the synced regulator tables, so a full batch of 1,000 takes well under a second. `data.results[]` mirrors the single-number response per item and each item's `meta` carries `credits_used`, `cache_hit`, `suppressed` and `opted_out`.

        Repeats inside the list are looked up once and answer as cache hits. The list is walked in order and stops where the balance would run out: running out of credits mid-batch returns the paid partial results with `meta.stopped_reason: "insufficient_credits"` and `processed` lower than `requested`, never discarding billed work. Only a batch whose first lookup is refused answers the typed `402`. A key with a daily credit cap stops the list the same way once the cap is spent: the numbers already paid for come back with `meta.stopped_reason: "key_spend_cap"`, and a call that starts with the cap already spent answers `429 KEY_SPEND_CAP_REACHED` and runs nothing. A string that is not a number is an answer (`valid: false`), not a validation error.

        A batch counts as one request against the rate limit. `country` applies to every number in the list that does not name its own. `hlr: true` adds the live carrier-network check to every item: the premium credits are reserved per item before its lookup and charged only where the network answered. `call_window` answers `in_call_window` for every item.

        Every item carries `index`, its zero-based position in the list you sent, and `input`, the number exactly as you sent it — the answer's `e164` is what the plan made of it. A batch stopped early by `stopped_reason` answers only a prefix of the list, so those two are what line an answer up with the row it came from.
      x-spaw-auth: bearer
      x-spaw-billing: 'Each number bills like a single lookup: 1 credit for a fresh valid answer; invalid numbers and 7-day repeats are free. The call stops cleanly where the balance ends.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - INSUFFICIENT_CREDITS
        - KEY_SPEND_CAP_REACHED
        - RATE_LIMITED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - phones
              properties:
                phones:
                  type: array
                  minItems: 1
                  maxItems: 1000
                  items:
                    type:
                      - string
                      - object
                    maxLength: 64
                    required:
                      - phone
                    properties:
                      phone:
                        type: string
                        maxLength: 64
                        description: 'The number, in any common notation.'
                      country:
                        type:
                          - string
                          - 'null'
                        description: "This item's own ISO 3166-1 alpha-2 region code, for a number written without a calling code. Wins over the list-level country."
                  description: '1 to 1,000 numbers, in any common notation. Each item is a plain string, or an object carrying its own country.'
                country:
                  type:
                    - string
                    - 'null'
                  description: 'An ISO 3166-1 alpha-2 region code used to parse every number written without a calling code, unless the item names its own.'
                hlr:
                  type: boolean
                  default: false
                  description: "Ask for the live carrier check on every number. While the check is not enabled on the service it answers `hlr_checked` false at no extra cost; when it is, each item that answers is charged the premium credits under the single-lookup rules (free where the account paid for that number's check in the last 7 days and the network's answer is still held), and the balance is gated per item before any network query."
                call_window:
                  type:
                    - string
                    - 'null'
                  pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]$'
                  example: '09:00-20:00'
                  description: 'Your calling window in each number''s local time, "HH:MM-HH:MM" from the start up to, not including, the end (a window may run past midnight), applied to every item. Answers `in_call_window`; the zone is where the number was issued, not where the person is, and the answer is arithmetic, not compliance advice.'
            example:
              phones:
                - '(415) 555-0142'
                -
                  phone: '020 3013 0018'
                  country: GB
                - 'not a number'
              country: US
      responses:
        200:
          description: 'One result per processed number, plus the batch totals.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PhoneBatchResponse'
              example:
                success: true
                data:
                  results:
                    -
                      index: 0
                      input: '(415) 555-0142'
                      data:
                        valid: true
                        reason: null
                        e164: '+14155550142'
                        country: US
                        line_type: fixed_line_or_mobile
                        is_fictional: true
                        risk_score: 80
                        risk_level: high
                        ok_to_send: false
                        blocked_by: fictional
                      meta:
                        credits_used: 1
                        cache_hit: false
                        suppressed: false
                        opted_out: false
                    -
                      index: 1
                      input: '020 3013 0018'
                      data:
                        valid: false
                        reason: unassigned_block
                        e164: '+442030130018'
                        country: GB
                        block_status: unassigned
                        is_fictional: false
                        risk_score: null
                        risk_level: null
                        ok_to_send: false
                        blocked_by: invalid
                      meta:
                        credits_used: 0
                        cache_hit: false
                        suppressed: false
                        opted_out: false
                    -
                      index: 2
                      input: 'not a number'
                      data:
                        valid: false
                        reason: not_a_number
                        e164: null
                        country: null
                        risk_score: null
                        risk_level: null
                        ok_to_send: false
                        blocked_by: invalid
                      meta:
                        credits_used: 0
                        cache_hit: false
                        suppressed: false
                        opted_out: false
                meta:
                  requested: 3
                  processed: 3
                  credits_used: 1
                  credits_remaining: 9
                  stopped_reason: null
                  request_id: req_01m1kgdrtqdvwnks99vfgx2rcw
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/phone/batch \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "phones": [
                "(415) 555-0142",
                {
                  "phone": "020 3013 0018",
                  "country": "GB"
                },
                "not a number"
              ],
              "country": "US"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/batch', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "phones": [
                  "(415) 555-0142",
                  {
                    "phone": "020 3013 0018",
                    "country": "GB"
                  },
                  "not a number"
                ],
                "country": "US"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/phone/batch',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'phones': [
                        '(415) 555-0142',
                        {
                            'phone': '020 3013 0018',
                            'country': 'GB'
                        },
                        'not a number'
                    ],
                    'country': 'US'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/batch');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'phones' => [
                        '(415) 555-0142',
                        [
                            'phone' => '020 3013 0018',
                            'country' => 'GB'
                        ],
                        'not a number'
                    ],
                    'country' => 'US'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.validate_phones([
                '(415) 555-0142',
                {
                    'phone': '020 3013 0018',
                    'country': 'GB'
                },
                'not a number'
            ], country='US')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.validatePhones([
                '(415) 555-0142',
                {
                    phone: '020 3013 0018',
                    country: 'GB'
                },
                'not a number'
            ], {
                country: 'US'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->validatePhones([
                '(415) 555-0142',
                [
                    'phone' => '020 3013 0018',
                    'country' => 'GB'
                ],
                'not a number'
            ], country: 'US');
  '/api/v1/phone/block/{country}/{prefix}':
    get:
      operationId: phone-block-report
      tags:
        - Phone
      summary: "Report a number range from the regulator's table"
      description: |
        What the national regulator's own allocation table says about every block under one prefix: how many blocks are allocated, unassigned or reserved, who holds them and what share each holds, the network brand behind each holder and the share each brand holds, how much of the range is mobile or VoIP, and the place, time zones and line types the numbering plan names for it. For the North American tables it also counts the codes pooled into thousands-blocks — whose holder may not hold every number in them — and spans the dates the codes were assigned. The report counts codes; a lookup of a US number names the holder of the number's own thousands-block where NANPA lists it. Original allocation only — never who serves a number today, which no open table publishes.

        It is the endpoint behind the public area-code pages, so screening a whole area code before a campaign, or checking a range a signup wave arrived from, is one call rather than a page scrape. `country` is an ISO 3166-1 alpha-2 code and `prefix` the digits of the range with no calling code: `/v1/phone/block/us/415`, `/v1/phone/block/gb/20`. A prefix written the way it is dialled inside the country, with the trunk prefix in front, is read without it, and the answer's `prefix`, the charge and the repeat marker are the stored form's: `gb/020` as `20`, `gb/0161` as `161` and `us/1212` as `212`. Italy, Poland and Mexico dial no trunk prefix, so their digits are read as written. A longer prefix narrows the report to the blocks underneath it.

        Guernsey, Jersey and the Isle of Man (`gg`, `je`, `im`) are answered from GB's table, as their lookups are, keeping only the blocks whose numbers are theirs. A country whose numbering plan Spaw does not carry, and a prefix no block starts with (for a Crown dependency, one that reaches outside its own numbers, such as `/v1/phone/block/gg/20`), both answer `404 PHONE_BLOCK_NOT_FOUND` and cost nothing.
      x-spaw-auth: bearer
      x-spaw-billing: '1 credit per answered report, free for the next seven days for the same prefix. A prefix or country with nothing to report costs nothing.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - INSUFFICIENT_CREDITS
        - PHONE_BLOCK_NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: country
          in: path
          required: true
          schema:
            type: string
            minLength: 2
            maxLength: 2
          example: us
          description: 'An ISO 3166-1 alpha-2 code, in either case.'
        -
          name: prefix
          in: path
          required: true
          schema:
            type: string
            pattern: '^[0-9]{1,15}$'
          example: '415'
          description: "The range, digits only and no calling code. A leading national trunk prefix (the UK's 0, the North American 1) is dropped before the table is read."
      responses:
        200:
          description: 'The report for that range.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/PhoneBlockReport'
                  meta:
                    type: object
                    properties:
                      credits_used:
                        type: integer
                      credits_remaining:
                        type: integer
                      cache_hit:
                        type: boolean
                      request_id:
                        type:
                          - string
                          - 'null'
              example:
                success: true
                data:
                  country: US
                  country_name: 'United States'
                  calling_code: 1
                  prefix: '415'
                  international_prefix: '+1 415'
                  place: 'San Francisco, CA'
                  places: []
                  timezones:
                    - America/Los_Angeles
                  line_types:
                    - fixed_line_or_mobile
                  example: '+1 415-200-0000'
                  blocks:
                    total: 792
                    allocated: 604
                    unassigned: 171
                    reserved: 17
                    pooled: 431
                  assigned:
                    dated: 262
                    first: '2001-01-12'
                    last: '2026-08-27'
                  block_length: 6
                  kinds:
                    mobile: 41.2
                    voip: 12.7
                    unsettled: 46.1
                  holders:
                    -
                      holder: 'PACIFIC BELL'
                      brand: 'AT&T'
                      kind: null
                      blocks: 96
                      share: 15.9
                    -
                      holder: 'T-MOBILE USA, INC.'
                      brand: T-Mobile
                      kind: mobile
                      blocks: 71
                      share: 11.8
                  brands:
                    -
                      brand: 'AT&T'
                      blocks: 158
                      share: 26.2
                    -
                      brand: T-Mobile
                      blocks: 104
                      share: 17.2
                  feed:
                    key: nanpa
                    dataset: nanpa-co-codes
                    version: '2026-09-04'
                    complete: true
                meta:
                  credits_used: 1
                  credits_remaining: 9
                  cache_hit: false
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        404:
          description: 'No numbering plan covers the country, or no block starts with the prefix. Nothing was charged.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: PHONE_BLOCK_NOT_FOUND
                  message: 'No number block in US starts with 999.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/phone/block/us/415 \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/block/us/415', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/phone/block/us/415',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/block/us/415');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.phone_block_report('us', '415')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.phoneBlockReport('us', '415');
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->phoneBlockReport('us', '415');
  /api/v1/phone/bulk:
    get:
      operationId: list-phone-bulk-jobs
      tags:
        - Phone
      summary: "List the account's bulk phone lookup runs"
      description: |
        Every bulk run this account has started, newest first, 100 per page. A job id is handed out once, at creation, and everything about a run hangs off it — its counts, its results, its cancel and resume calls — so this is how a caller that lost one finds the run again, and how a dashboard lists what is still in flight.

        `status`, `since` and `until` are optional and combine; a bare date in `until` means the whole of that day, and an unknown status or an unparseable date is a `422` rather than an empty page. The rows are the same job objects `GET /api/v1/phone/bulk/{jobId}` answers. The webhook secret is never among them: it is returned once, at creation.

        Runs are kept as long as the lookup history, and `meta.retention_days` says how long that is — a run older than that is deleted with its files and cannot be listed.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      parameters:
        -
          name: status
          in: query
          required: false
          schema:
            type: string
            enum:
              - queued
              - processing
              - completed
              - failed
              - cancelled
          example: completed
          description: 'Only runs in this status.'
        -
          name: since
          in: query
          required: false
          schema:
            type: string
          example: '2026-09-01'
          description: 'Only runs created at or after this date or timestamp.'
        -
          name: until
          in: query
          required: false
          schema:
            type: string
          example: '2026-09-08'
          description: 'Only runs created at or before this. A bare date means the whole of that day.'
        -
          name: page
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            default: 1
          example: 1
          description: 'The page to return.'
      responses:
        200:
          description: 'One page of runs, newest first.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      jobs:
                        type: array
                        items:
                          $ref: '#/components/schemas/PhoneBulkJob'
                  meta:
                    type: object
                    properties:
                      page:
                        type: integer
                      per_page:
                        type: integer
                      total:
                        type: integer
                      retention_days:
                        type: integer
                        description: 'How long a run and its files are kept before they are deleted.'
              example:
                success: true
                data:
                  jobs:
                    -
                      id: 88
                      status: completed
                      total: 5000
                      duplicate_count: 40
                      processed: 5000
                      valid: 4610
                      invalid: 390
                      low: 4200
                      medium: 380
                      high: 30
                      credits_used: 4610
                      stopped_reason: null
                      cancel_requested: false
                      country: US
                      live_check: false
                      webhook_status: null
                      webhook_detail: null
                      created_at: '2026-09-05T09:00:00+00:00'
                      finished_at: '2026-09-05T09:04:11+00:00'
                meta:
                  page: 1
                  per_page: 100
                  total: 1
                  retention_days: 30
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/phone/bulk \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/bulk', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/phone/bulk',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/bulk');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.list_phone_bulk_jobs()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.listPhoneBulkJobs();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->listPhoneBulkJobs();
    post:
      operationId: create-phone-bulk-job
      tags:
        - Phone
      summary: 'Queue a bulk phone lookup run'
      description: |
        Queues up to 100,000 numbers and processes them in the background. Creation answers `202` with the job's id and status. Poll `GET /api/v1/phone/bulk/{jobId}` for progress, then download the result CSV from `GET /api/v1/phone/bulk/{jobId}/results`: one row per input number with every response field as a column. Jobs and their files are kept for 30 days.

        Rows repeating a number already in the list (as submitted) are looked up once and counted in `duplicate_count`. A run that exhausts the balance stops with `stopped_reason: "insufficient_credits"` and keeps everything answered so far. Because nothing about a phone lookup is stored, the input list and the result file are the only copies and both are deleted with the job. `country` applies to every row; the live carrier-network check is single lookups only.

        With a `webhook_url`, the finish (`completed`, `failed`, or `cancelled`) is POSTed there as `{ "event": "bulk_phone_job.finished", "job": { … } }`, signed with `webhook_secret` exactly like bulk email and IP runs: `X-Spaw-Signature-V2` carries `t={unix seconds},v2={lowercase hex}`, the HMAC-SHA256 of `v2:{t}:{raw body}`, and a receiver should refuse a `t` more than five minutes from its own clock so a captured delivery cannot be replayed at it later. The original `X-Spaw-Signature`, over the body alone, is sent beside it until 1 March 2027. Send an `Idempotency-Key` header to make creation safe to retry: a repeat of the same request answers the job the first attempt created, as `200` with `Idempotent-Replayed: true`; the same key with a different request answers `409 IDEMPOTENCY_KEY_REUSED`.

        ### Reading a list before you start it

        Send `preview: true` with the same body — or `csv` with a raw CSV instead of the item list — and the call answers `200` with `data.draft` instead of queueing anything: what the list was read as, which column filled which field and whether a heading named it or we guessed, the first rows as parsed, the repeats, the balance, and `credits_upper_bound`. Nothing is charged.

        `credits_upper_bound` is a CEILING, not a quote. Rows come back free for reasons no preview can know in advance — a repeat inside the seven-day window, an entry on your suppression list, a verdict that is not charged for, a provider outage — so a run usually costs less than the ceiling and never more. It is priced against the mapping in play, so correcting a column at start re-prices the run rather than holding you to the figure the guess produced — ask for the draft again after a correction if you want the new number. What it actually cost is `credits_used` on the finished job.

        Start it with `{"draft_id": <id>}`, adding `mapping` to correct a column we read wrongly. Starting consumes the draft; one left unstarted is deleted, with the rows it holds, after `retention_hours`.
      x-spaw-auth: bearer
      x-spaw-billing: '`preview: true` is free and queues nothing. A started run bills each row like a single lookup as it is processed; invalid numbers, repeats inside the run and 7-day repeats are free, so a run costs at most the `credits_upper_bound` the preview answered and usually less.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - IDEMPOTENCY_KEY_REUSED
        - RATE_LIMITED
      parameters:
        -
          name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            minLength: 1
            maxLength: 128
          description: 'A value unique to this request, so a retry after a timeout answers the same job instead of queueing a second one.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                phones:
                  type: array
                  minItems: 1
                  maxItems: 100000
                  items:
                    type:
                      - string
                      - object
                    maxLength: 64
                    required:
                      - phone
                    properties:
                      phone:
                        type: string
                        maxLength: 64
                        description: 'The number, in any common notation.'
                      country:
                        type:
                          - string
                          - 'null'
                        description: "This item's own ISO 3166-1 alpha-2 region code, for a number written without a calling code. Wins over the list-level country."
                  description: '1 to 100,000 numbers, in any common notation. Each item is a plain string, or an object carrying its own country. Required unless the body carries `csv` with `preview: true`, or `draft_id`.'
                country:
                  type:
                    - string
                    - 'null'
                  description: 'An ISO 3166-1 alpha-2 region code used to parse every number written without a calling code, unless the item names its own.'
                hlr:
                  type: boolean
                  default: false
                  description: "Ask for the live carrier check on every number. While the check is not enabled on the service it answers `hlr_checked` false at no extra cost; when it is, each item that answers is charged the premium credits under the single-lookup rules (free where the account paid for that number's check in the last 7 days and the network's answer is still held), and the balance is gated per item before any network query."
                preview:
                  type: boolean
                  default: false
                  description: 'Read the list and answer `data.draft` — what we made of it, the repeats, the ceiling on the cost and the balance — with nothing queued and nothing billed. Start it afterwards with `draft_id`.'
                csv:
                  type:
                    - string
                    - 'null'
                  maxLength: 5000000
                  description: "A raw CSV to read instead of the item list, with the column mapping answered back. Preview only, on purpose: a CSV's columns have to be worked out, and working them out and billing for the answer in one request is what the preview exists to stop."
                draft_id:
                  type:
                    - integer
                    - 'null'
                  description: 'Start the run a preview answered with. The rows and the options come from the draft, so nothing else in the body applies except `mapping`. A draft can be started once; an `Idempotency-Key` still makes the call safe to retry.'
                mapping:
                  type:
                    - object
                    - 'null'
                  additionalProperties:
                    type: string
                  description: 'Corrects the draft''s column mapping, as column index to field — {"1": "address_line1", "2": "postal_code"}. It REPLACES the detected mapping rather than merging into it, so a column can be taken off a field as well as put on one. Goes with `draft_id`, and only for a draft read from a `csv`.'
                webhook_url:
                  type:
                    - string
                    - 'null'
                  format: uri
                  description: 'An https URL to notify when the run finishes.'
            example:
              phones:
                - '+1 415 555 0142'
                - '(415) 555-0143'
              country: US
              webhook_url: 'https://example.com/hooks/spaw'
      responses:
        202:
          description: 'The job was queued.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        allOf:
                          -
                            $ref: '#/components/schemas/PhoneBulkJob'
                          -
                            type: object
                            properties:
                              webhook_secret:
                                type:
                                  - string
                                  - 'null'
                                description: 'Returned once. Verify webhook signatures with it.'
              example:
                success: true
                data:
                  job:
                    id: 733
                    status: queued
                    total: 2
                    duplicate_count: 0
                    processed: 0
                    valid: 0
                    invalid: 0
                    low: 0
                    medium: 0
                    high: 0
                    credits_used: 0
                    stopped_reason: null
                    cancel_requested: false
                    country: US
                    webhook_status: null
                    created_at: '2026-09-04T10:12:44+00:00'
                    finished_at: null
                    webhook_secret: '8fJ2…40 characters…Qk1'
        200:
          description: 'Either a preview — `preview: true` answers `data.draft` and queues nothing — or a retry with an `Idempotency-Key` already used for this exact request, which answers `data.job` with `Idempotent-Replayed: true`.'
          headers:
            Idempotent-Replayed:
              schema:
                type: string
                enum:
                  - 'true'
              description: 'Present only on a replayed creation.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      draft:
                        $ref: '#/components/schemas/BulkDraft'
                      job:
                        $ref: '#/components/schemas/PhoneBulkJob'
              example:
                success: true
                data:
                  job:
                    id: 733
                    status: processing
                    total: 2
                    duplicate_count: 0
                    processed: 1
                    valid: 1
                    invalid: 0
                    low: 0
                    medium: 0
                    high: 1
                    credits_used: 1
                    stopped_reason: null
                    cancel_requested: false
                    country: US
                    webhook_status: null
                    created_at: '2026-09-04T10:12:44+00:00'
                    finished_at: null
        401:
          $ref: '#/components/responses/Unauthenticated'
        409:
          description: 'The `Idempotency-Key` was already used for a different list, country or webhook URL.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: IDEMPOTENCY_KEY_REUSED
                  message: 'This Idempotency-Key was already used for a different request.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/phone/bulk \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "phones": [
                "+1 415 555 0142",
                "(415) 555-0143"
              ],
              "country": "US",
              "webhook_url": "https://example.com/hooks/spaw"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/bulk', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "phones": [
                  "+1 415 555 0142",
                  "(415) 555-0143"
                ],
                "country": "US",
                "webhook_url": "https://example.com/hooks/spaw"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/phone/bulk',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'phones': [
                        '+1 415 555 0142',
                        '(415) 555-0143'
                    ],
                    'country': 'US',
                    'webhook_url': 'https://example.com/hooks/spaw'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/bulk');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'phones' => [
                        '+1 415 555 0142',
                        '(415) 555-0143'
                    ],
                    'country' => 'US',
                    'webhook_url' => 'https://example.com/hooks/spaw'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.create_phone_bulk_job([
                '+1 415 555 0142',
                '(415) 555-0143'
            ], country='US', webhook_url='https://example.com/hooks/spaw')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.createPhoneBulkJob([
                '+1 415 555 0142',
                '(415) 555-0143'
            ], {
                country: 'US',
                webhookUrl: 'https://example.com/hooks/spaw'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->createPhoneBulkJob([
                '+1 415 555 0142',
                '(415) 555-0143'
            ], country: 'US', webhookUrl: 'https://example.com/hooks/spaw');
  '/api/v1/phone/bulk/{jobId}':
    get:
      operationId: get-phone-bulk-job
      tags:
        - Phone
      summary: 'Poll a bulk phone job'
      description: |
        The job's current status and running counts. `status` moves from `queued` to `processing` and ends in `completed`, `failed`, or `cancelled`; `processed`, the `valid` and `invalid` counts and the `low`, `medium` and `high` risk counts advance as chunks finish. A job that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'The job.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        $ref: '#/components/schemas/PhoneBulkJob'
              example:
                success: true
                data:
                  job:
                    id: 733
                    status: completed
                    total: 2
                    duplicate_count: 0
                    processed: 2
                    valid: 2
                    invalid: 0
                    low: 1
                    medium: 0
                    high: 1
                    credits_used: 2
                    stopped_reason: null
                    cancel_requested: false
                    country: US
                    webhook_status: delivered
                    created_at: '2026-09-04T10:12:44+00:00'
                    finished_at: '2026-09-04T10:12:46+00:00'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/phone/bulk/{jobId} \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/bulk/{jobId}', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/phone/bulk/{jobId}',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/bulk/{jobId}');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.get_phone_bulk_job(job_id)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.getPhoneBulkJob(jobId);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->getPhoneBulkJob($jobId);
  '/api/v1/phone/bulk/{jobId}/cancel':
    post:
      operationId: cancel-phone-bulk-job
      tags:
        - Phone
      summary: 'Cancel a bulk phone job'
      description: |
        Asks a queued or running job to stop at its next checkpoint. Everything answered so far stays billed and stays in the result file. A job that already finished answers `409 JOB_ALREADY_FINISHED`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - JOB_ALREADY_FINISHED
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
      responses:
        202:
          description: 'Cancellation was requested.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        $ref: '#/components/schemas/PhoneBulkJob'
              example:
                success: true
                data:
                  job:
                    id: 733
                    status: processing
                    total: 20000
                    duplicate_count: 12
                    processed: 4000
                    valid: 3700
                    invalid: 300
                    low: 3100
                    medium: 400
                    high: 200
                    credits_used: 3700
                    stopped_reason: null
                    cancel_requested: true
                    country: null
                    webhook_status: null
                    created_at: '2026-09-04T10:12:44+00:00'
                    finished_at: null
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        409:
          description: 'The job already finished.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: JOB_ALREADY_FINISHED
                  message: 'This job has already finished.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/phone/bulk/{jobId}/cancel \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/bulk/{jobId}/cancel', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/phone/bulk/{jobId}/cancel',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/bulk/{jobId}/cancel');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.cancel_phone_bulk_job(job_id)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.cancelPhoneBulkJob(jobId);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->cancelPhoneBulkJob($jobId);
  '/api/v1/phone/bulk/{jobId}/resume':
    post:
      operationId: resume-phone-bulk-job
      tags:
        - Phone
      summary: 'Resume a bulk job that ran out of credits'
      description: |
        Runs a job that stopped when the credit balance ran out again, from the top of the list it was given, instead of asking you to upload it a second time. Rows looked up in the last seven days come back as free repeats, so only the rows the stopped run never reached are charged. On a run with `live_check`, a number whose network answer is more than a day old is asked again and charged the premium, even inside the seven days, so resume within a day to keep those rows free.

        The job keeps its id, its webhook and its cumulative `credits_used`, which is the real spend on the account. `processed` and the verdict counts restart at zero: the part files they were counted from are deleted when a run settles, so the resumed run rebuilds them, and the result file the stopped run left is replaced as the rows come back.

        Only a run whose `stopped_reason` is `insufficient_credits` and whose `status` has settled can be resumed; anything else answers `409 JOB_NOT_RESUMABLE`, and so does a second resume of a run that is already going again — claiming the run is one conditional write, so two calls can never queue it twice. A run whose list has passed out of the retention window answers `409 JOB_INPUT_EXPIRED`. An empty balance answers `402 INSUFFICIENT_CREDITS` rather than queueing a run that would stop again on the first row it never reached.
      x-spaw-auth: bearer
      x-spaw-billing: 'Free to call. The resumed run bills the rows it reaches exactly as the first one did, and a row answered in the last seven days is a free repeat; with `live_check`, a row whose network answer is more than a day old is asked again and charged the premium.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - JOB_NOT_RESUMABLE
        - JOB_INPUT_EXPIRED
        - INSUFFICIENT_CREDITS
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
      responses:
        202:
          description: 'The list is queued again.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        $ref: '#/components/schemas/PhoneBulkJob'
              example:
                success: true
                data:
                  job:
                    id: 733
                    status: queued
                    total: 20000
                    duplicate_count: 12
                    processed: 0
                    valid: 0
                    invalid: 0
                    low: 0
                    medium: 0
                    high: 0
                    credits_used: 3700
                    stopped_reason: null
                    cancel_requested: false
                    country: null
                    webhook_status: null
                    created_at: '2026-09-04T10:12:44+00:00'
                    finished_at: null
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        404:
          $ref: '#/components/responses/NotFound'
        409:
          description: 'The run is not in a state a resume can recover (`JOB_NOT_RESUMABLE`), or its list is no longer stored (`JOB_INPUT_EXPIRED`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: JOB_NOT_RESUMABLE
                  message: 'Only a run that stopped because the credit balance ran out can be resumed, and only once it has finished settling.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/phone/bulk/{jobId}/resume \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/bulk/{jobId}/resume', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/phone/bulk/{jobId}/resume',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/bulk/{jobId}/resume');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.resume_phone_bulk_job(job_id)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.resumePhoneBulkJob(jobId);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->resumePhoneBulkJob($jobId);
  '/api/v1/phone/bulk/{jobId}/webhook/redeliver':
    post:
      operationId: redeliver-phone-bulk-webhook
      tags:
        - Phone
      summary: "Send a finished run's completion webhook again"
      description: |
        POSTs a finished run's completion webhook to its `webhook_url` a second time. This is for the case the webhook exists to cover: the run finished, the delivery went out, your endpoint was down for the few minutes it was tried, and the message is gone. `webhook_status` and `webhook_detail` tell you that happened; this is how you ask for it again, instead of falling back to polling — which is the thing the webhook was set up to avoid.

        **The body is the same body.** It is rebuilt from the run's own row rather than replayed from a stored blob, and every field it carries — `status`, `processed`, the row counts, `credits_used`, `stopped_reason`, `finished_at` — is frozen once a run has settled. The bytes are the ones the first delivery carried, so reconciling a redelivery against the original compares equals. (A run that is resumed later settles again and posts a new completion webhook of its own; a redelivery always repeats the most recent one.)

        **The signature is new, and that is correct.** Each delivery is signed as it is sent, so `X-Spaw-Signature-V2` carries a later `t` and a different `v2` digest from the first attempt. That is required rather than incidental: a receiver refuses anything whose `t` is more than five minutes from its own clock, so a signature copied from the original would be refused on arrival. Verify a redelivery exactly as you verify any other delivery. The untimestamped `X-Spaw-Signature` is the HMAC of the body alone, so it *is* identical to the first attempt's, until it stops being sent on 1 March 2027.

        **Nothing deduplicates this for you.** Each call is one real POST, so a receiver that gets both the original and the redelivery sees the event twice. Both bodies carry `job.id`: treat a `bulk_phone_job.finished` for a job id you have already handled as a repeat and drop it.

        **The delivery is queued, and the answer is `202`.** One attempt is a 10-second timeout retried twice, about half a minute against a receiver that is down — which is the receiver you are asking about. So this call does not wait for it: it answers `202` at once with the run, and `webhook_status` on it reads `pending`, a delivery asked for and not yet answered. Poll the run (`GET /api/v1/phone/bulk/{jobId}`) until `webhook_status` is `delivered` or `failed`; `webhook_detail` then carries the HTTP status your endpoint gave, or the transport failure when it gave none. A `pending` run has one delivery in flight and asking again queues a second — wait for the first before you decide it failed.

        A run created without a `webhook_url` answers `409 WEBHOOK_NOT_CONFIGURED`, which is checked FIRST because waiting will never fix it; one still queued or running answers `409 JOB_NOT_FINISHED`, because there is no completion to announce yet, and so does a run that stops being finished between the check and the queueing (a resume in another window). A run whose last delivery *succeeded* is not refused — a receiver can lose a message it has already acknowledged, and only you know whether it did.

        A URL that resolves onto a private address is refused at delivery here exactly as on the first attempt, every time, because DNS can be repointed after a URL is saved.
      x-spaw-auth: bearer
      x-spaw-billing: "Free. A redelivery spends no credits and bills nothing. It is capped at 10 a minute counted per signed-in user — neither per key nor per account, so two keys held by one person share the one budget while two teammates have one each, and the dashboard button and the other products' redeliveries all spend the caller's own."
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - WEBHOOK_NOT_CONFIGURED
        - JOB_NOT_FINISHED
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
          example: 733
          description: 'The id returned at creation.'
      responses:
        202:
          description: 'The delivery is queued; `webhook_status` reads `pending` until a worker has an answer.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        $ref: '#/components/schemas/PhoneBulkJob'
              example:
                success: true
                data:
                  job:
                    id: 733
                    status: completed
                    total: 20000
                    duplicate_count: 12
                    processed: 20000
                    valid: 17400
                    invalid: 2600
                    low: 15200
                    medium: 1900
                    high: 300
                    credits_used: 17400
                    stopped_reason: null
                    cancel_requested: false
                    country: null
                    webhook_status: pending
                    webhook_detail: null
                    created_at: '2026-09-04T10:12:44+00:00'
                    finished_at: '2026-09-04T10:58:11+00:00'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        409:
          description: 'The run has no webhook URL (`WEBHOOK_NOT_CONFIGURED`), or it has not finished yet (`JOB_NOT_FINISHED`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: WEBHOOK_NOT_CONFIGURED
                  message: 'This run was created without a webhook URL, so there is no completion webhook to send.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          description: 'More than ten redeliveries in a minute from this user, across every product and the dashboard button alike. The shared limit named on other endpoints is not the one that fires here.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: RATE_LIMITED
                  message: 'Too many requests. Retry after the limit resets.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/phone/bulk/733/webhook/redeliver \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/bulk/733/webhook/redeliver', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/phone/bulk/733/webhook/redeliver',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/bulk/733/webhook/redeliver');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.redeliver_phone_bulk_webhook(733)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.redeliverPhoneBulkWebhook(733);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->redeliverPhoneBulkWebhook(733);
  '/api/v1/phone/bulk/{jobId}/results':
    get:
      operationId: download-phone-bulk-results
      tags:
        - Phone
      summary: "Download a bulk phone job's results"
      description: |
        The result file as CSV: one row per input number, in input order, with the number as submitted, every field of the single-number response as a column except four a run never fills (`range_seen_by_you`, `client_velocity` and `destination_new_for_you`, because a run is not counted in your own traffic, and `in_call_window`, because a run takes no call window; the live-check fields stay empty unless the run asked for the check, and `local_time` and `utc_offsets` are the clock when the row was processed), the credits it cost, whether the suppression list answered it (`suppressed`), and whether an earlier row of the list already held the same number in any spelling (`is_duplicate`, with `duplicate_of_row` naming that row's 1-based position in the list, header not counted; a repeat is looked up once and is free). Time zones, UTC offsets and sources are joined by `;` (sources as `dataset@version`), risk signals as `signal:weight` pairs. Fields containing spaces or commas are quoted. Add `?variant=` to download only some rows: `valid` or `invalid`; one risk level (`low`, `medium`, `high`); `deduped`, the valid rows that are the first in the list to hold their number; `mobile`, the valid rows whose `line_type` is mobile — most North American numbers answer `fixed_line_or_mobile` because mobiles and landlines share blocks there, and are not in it; `ok`, the rows your phone policy answered `ok_to_send` true for; or `blocked`, the rows it held back, with `blocked_by` saying why. The default `full` returns everything. Available once the job is `completed`, or `cancelled` or `failed` with at least one processed row — those rows were billed, so they stay downloadable. A job still running answers `409 RESULTS_NOT_READY`, a cancelled job with nothing processed `409 JOB_CANCELLED`, and a failed job with nothing processed `409 JOB_FAILED`.

        `format=json` answers the same rows, filtered the same way, as objects keyed by the result file's own header instead of a CSV attachment: `{ "success": true, "data": { "job": …, "results": [ … ] }, "meta": { "variant", "request_id" } }`. It is streamed row by row exactly as the CSV is, so a 100,000-row run costs no more to answer either way. A CSV cell carries no type, so only the two the writer encodes come back typed: an empty cell is `null` and `true`/`false` are booleans. Everything else is the string as written — a number is never silently made numeric, because postcodes, phone numbers and house numbers lose their leading zeros that way — and the columns of an uploaded file come back exactly as they were uploaded.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - VALIDATION_FAILED
        - RESULTS_NOT_READY
        - JOB_CANCELLED
        - JOB_FAILED
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
        -
          name: variant
          in: query
          required: false
          schema:
            type: string
            enum:
              - full
              - valid
              - invalid
              - low
              - medium
              - high
              - deduped
              - mobile
              - ok
              - blocked
          description: 'Restrict the file to the valid or invalid rows, one risk level, the first row of each valid number (`deduped`), the valid mobile rows, or the rows your phone policy lets through (`ok`) or holds back (`blocked`). Defaults to full.'
        -
          name: format
          in: query
          required: false
          schema:
            type: string
            enum:
              - csv
              - json
            default: csv
          example: json
          description: '`csv` streams the file as an attachment; `json` streams the same rows as objects.'
      responses:
        200:
          description: 'The result rows, as a CSV attachment or as streamed JSON.'
          content:
            text/csv:
              schema:
                type: string
              example: |
                phone,valid,reason,e164,national,international,rfc3966,country,country_name,calling_code,continent,is_eu,currency,line_type,line_type_source,carrier,carrier_source,block_status,region,is_geographic,timezones,extension,is_fictional,is_virtual,is_disposable,disposable_seen_at,risk_score,risk_level,risk_signals,hlr_checked,reachable,ported,current_carrier,roaming,carrier_code,carrier_brand,block_pooled,block_assigned_at,region_level,local_time,utc_offsets,did_you_mean,complaints_90d,complaints_last_at,ok_to_send,blocked_by,sources,credits_used,suppressed,is_duplicate,duplicate_of_row
                "+1 415 555 0142",true,,+14155550142,"(415) 555-0142","+1 415-555-0142",tel:+1-415-555-0142,US,"United States",1,NA,false,USD,fixed_line_or_mobile,numbering_plan,,,reserved,"San Francisco, CA",true,America/Los_Angeles,,true,false,false,,100,high,fictional:80;reserved_block:40,false,,,,,,,,,locality,2026-09-24T14:20:06-07:00,-07:00,,0,,false,fictional,libphonenumber@9.0.37;nanpa-co-codes@2026-09-24;fictional-ranges@2026-09-25;disposable-phone-numbers@2026-09-24;us-complaint-reports@2026-09-24,1,false,false,
            application/json:
              schema:
                $ref: '#/components/schemas/BulkResultRows'
              example:
                success: true
                data:
                  job:
                    id: 88
                    status: completed
                  results:
                    -
                      phone: '+1 415 555 0142'
                      valid: true
                      reason: null
                      e164: '+14155550142'
                      country: US
                      line_type: fixed_line_or_mobile
                      risk_score: '100'
                      risk_level: high
                      ok_to_send: 'false'
                      blocked_by: fictional
                      credits_used: '1'
                meta:
                  variant: full
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        409:
          description: 'The job is still running (`RESULTS_NOT_READY`), or was cancelled or failed before any row was processed (`JOB_CANCELLED`, `JOB_FAILED`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: RESULTS_NOT_READY
                  message: 'The results are not ready yet.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/phone/bulk/{jobId}/results \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/bulk/{jobId}/results', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/phone/bulk/{jobId}/results',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/bulk/{jobId}/results');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.download_phone_bulk_results(job_id)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.downloadPhoneBulkResults(jobId);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->downloadPhoneBulkResults($jobId);
  /api/v1/phone/public:
    post:
      operationId: validate-phone-public
      tags:
        - Phone
      summary: 'Validate a number from the browser with a publishable key'
      description: |
        The browser counterpart of `POST /api/v1/phone`, for checking a phone field in a signup form on the client without exposing a secret key; it is what the form helper's `data-spaw-phone` inputs call. It is authenticated by a publishable `pk_` key in the body plus the browser's `Origin` header, which must match one of the domains the key is locked to. A missing `Origin` is rejected on purpose: servers use a secret key. Pass `country` (an ISO 3166-1 alpha-2 code, usually from a country select on the same form) so numbers typed without a calling code parse.

        The lookup bills the key's owner under the normal rules (a fresh valid answer costs one credit; invalid numbers, unassigned blocks and 7-day repeats are free) and answers `{ "success", "data" }` with **no meta block**, so page visitors never see the owner's balance. `ok_to_send` and `blocked_by` are the key owner's SMS policy applied to the answer: with `block_opted_out` on (the default), a number the owner's suppression list holds as opted out answers `blocked_by: opted_out`, so the page can ask the visitor to opt in again. The live carrier check is never offered here: an `hlr` field in the body is ignored. Because the key sits in page source, give it a daily credit cap in the dashboard: once the cap is spent the endpoint answers `429 KEY_SPEND_CAP_REACHED` until the next day. Free answers never count toward the cap. A key that carries a Cloudflare Turnstile pair requires a confirmed `turnstile_token` and answers `403 TURNSTILE_FAILED` without one, before any credit is spent.

        Throttled at 20 requests per minute per IP.
      security: []
      x-spaw-auth: publishable-key
      x-spaw-billing: "Bills the key owner like a single lookup; capped by the key's daily credit cap when one is set."
      x-spaw-errors:
        - INVALID_PUBLISHABLE_KEY
        - ORIGIN_NOT_ALLOWED
        - TURNSTILE_FAILED
        - KEY_SPEND_CAP_REACHED
        - VALIDATION_FAILED
        - INSUFFICIENT_CREDITS
        - RATE_LIMITED
      parameters:
        -
          name: Origin
          in: header
          required: true
          schema:
            type: string
          example: 'https://www.example.com'
          description: "Sent by browsers automatically. Its host must be on the key's allowed-domain list."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - key
                - phone
              properties:
                key:
                  type: string
                  maxLength: 64
                  description: 'A publishable key, which starts with `pk_`.'
                phone:
                  type: string
                  maxLength: 64
                  description: 'The number, in any common notation. Include the country calling code or pass `country`.'
                country:
                  type:
                    - string
                    - 'null'
                  description: "An ISO 3166-1 alpha-2 region code used to parse numbers written without a calling code, and to recognise that region's emergency numbers and short codes."
                turnstile_token:
                  type:
                    - string
                    - 'null'
                  maxLength: 2048
                  description: 'Required when the key carries a Turnstile pair. A single-use token from the Turnstile widget on the page.'
            example:
              key: pk_live_…
              phone: '07911 012345'
              country: GB
      responses:
        200:
          description: "The same fields as `POST /api/v1/phone`, without a meta block. `range_seen_by_you`, `client_velocity` and `destination_new_for_you` are always null here, because the answer reaches the visitor's browser; the visitor's own address is what `numbers_per_client` counts, and the signals still score."
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/PhoneResult'
              example:
                success: true
                data:
                  valid: true
                  reason: null
                  e164: '+447911012345'
                  international: '+44 7911 012345'
                  country: GB
                  line_type: mobile
                  line_type_source: numbering_plan
                  carrier: 'Marathon Telecom Limited'
                  carrier_source: block_allocation
                  block_status: allocated
                  is_fictional: false
                  is_virtual: false
                  is_disposable: false
                  risk_score: 0
                  risk_level: low
                  hlr_checked: false
                  ok_to_send: true
                  blocked_by: null
        401:
          description: 'The publishable key does not exist or was revoked.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: INVALID_PUBLISHABLE_KEY
                  message: 'That publishable key does not exist or was revoked.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        402:
          $ref: '#/components/responses/InsufficientCredits'
        403:
          description: "The page's origin is not on the key's allowed-domain list (`ORIGIN_NOT_ALLOWED`), or the key requires a Turnstile token that was missing or not confirmed (`TURNSTILE_FAILED`)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: ORIGIN_NOT_ALLOWED
                  message: 'This publishable key cannot be used from this origin.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          description: "The key has spent its owner's daily credit cap (`KEY_SPEND_CAP_REACHED`), or the IP exceeded 20 requests per minute (`RATE_LIMITED`)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: KEY_SPEND_CAP_REACHED
                  message: 'This publishable key has reached its daily credit cap. The counter resets each day.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/phone/public \
              -H "Origin: https://www.example.com" \
              -H "Content-Type: application/json" \
              -d '{
              "key": "pk_live_…",
              "phone": "07911 012345",
              "country": "GB"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/public', {
              method: 'POST',
              headers: {
                'Origin': 'https://www.example.com',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "key": "pk_live_…",
                "phone": "07911 012345",
                "country": "GB"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/phone/public',
                headers={'Origin': 'https://www.example.com'},
                json={
                    'key': 'pk_live_…',
                    'phone': '07911 012345',
                    'country': 'GB'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/public');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Origin: https://www.example.com', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'key' => 'pk_live_…',
                    'phone' => '07911 012345',
                    'country' => 'GB'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
  /api/v1/phone/feedback:
    post:
      operationId: report-phone-feedback
      tags:
        - Phone
      summary: 'Report SMS outcomes'
      description: |
        Tells Spaw what happened after you sent an SMS to a number: `delivered`, `undelivered` (the carrier reported it could not be delivered), `abused` (the code was used by someone who was not the recipient, or the number was involved in fraud), `verified` (the recipient entered the code the SMS carried) or `opted_out` (the recipient unsubscribed, for instance by replying STOP). Your own reports feed your own later lookups of that number as risk signals with the dataset `delivery-feedback`: a number whose latest report within 90 days is a failure about the number scores `reported_undelivered` (+50), a number with any abuse report within 90 days scores `reported_abuse` (+60), and a delivery or a verification reported after a failure cancels that failure. No other account ever sees your reports.

        `verified` counts as a delivery everywhere a delivery counts: it cancels an earlier failure and clears a suppression entry your failure reports added. An abuse report is not a failure: the entry it adds, reason `abuse`, stays until you remove it — no delivery, verification, lookup or age takes it off, even once the report has left the 90 days it scores for. Only an `abused` item in the request that carries it adds or relabels an `abuse` entry, so an abuse entry you removed does not come back from a later delivered, verified or undelivered report while the old report is still in its 90 days; only the next abuse report restores it (an opt-out, or failures that would suppress any number, still add it with reason `opted_out` or `undelivered`). On a number you imported, an abuse report is recorded on the import, which keeps source `imported`. Report it beside your delivery receipts and the summary's `verified_share` reads as your OTP conversion — traffic pumping is delivered and never verified.

        `opted_out` is never a risk signal: it says nothing about whether the number works. It puts the number on your suppression list with reason `opted_out` — recorded on the entry you imported, if you did, which keeps source `imported` — where batch, bulk and monitor runs serve it suppressed. A single lookup answers and scores it in full with `meta.opted_out: true` and, under the default SMS policy, `ok_to_send: false` with `blocked_by: opted_out`; an account that sends one-time codes may switch `block_opted_out` off (`PUT /api/v1/phone/policy`). Nothing but removing the entry takes it off — not a later delivery, a verification, a lookup or age — and removing it stands until the next opt-out is reported. It is your own record: honouring an opt-out stays your obligation, and Spaw is not a consent record.

        An `undelivered` item may name its `failure_class`, read from your SMS provider's error code: `permanent` (the number no longer exists or is barred for good), `transient` (the handset was off, out of coverage or busy), `not_sms_capable` (a landline or another number that cannot take SMS) or `sender_side` (a carrier filter, an unregistered campaign or sender, a rate limit — your side of the send, not the number). Only failures about the number count: `permanent`, `transient` and failures sent without a class feed `reported_undelivered` and your suppression list, where one `permanent` failure suppresses the number at once and the others need two with no delivery after them. `not_sms_capable` and `sender_side` failures are kept and counted in the summary, never scored. `occurred_at` may not be more than five minutes in the future; a time inside that margin is recorded as now.

        Numbers are normalised like a lookup; national notation needs a `country` on the item or on the request. Items whose number does not parse as a valid subscriber number are skipped and counted, not rejected. Feedback is free and never logged as a lookup. Outcomes are kept for 180 days.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - items
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 1000
                  items:
                    $ref: '#/components/schemas/PhoneFeedbackItem'
                  description: '1 to 1,000 outcomes per request.'
                country:
                  type:
                    - string
                    - 'null'
                  description: 'An ISO 3166-1 alpha-2 region applied to items that do not name their own, for numbers written without a calling code.'
            example:
              items:
                -
                  phone: '+44 7911 012345'
                  outcome: undelivered
                  failure_class: transient
                -
                  phone: '(415) 555-0142'
                  country: US
                  outcome: delivered
                  occurred_at: '2026-09-05T09:00:00Z'
                -
                  phone: '(415) 555-0142'
                  country: US
                  outcome: verified
                  occurred_at: '2026-09-05T09:01:10Z'
      responses:
        202:
          description: 'The outcomes were recorded.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      recorded:
                        type: integer
                      skipped:
                        type: integer
                        description: 'Items whose number did not parse as a valid subscriber number.'
              example:
                success: true
                data:
                  recorded: 2
                  skipped: 0
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/phone/feedback \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "items": [
                {
                  "phone": "+44 7911 012345",
                  "outcome": "undelivered",
                  "failure_class": "transient"
                },
                {
                  "phone": "(415) 555-0142",
                  "country": "US",
                  "outcome": "delivered",
                  "occurred_at": "2026-09-05T09:00:00Z"
                },
                {
                  "phone": "(415) 555-0142",
                  "country": "US",
                  "outcome": "verified",
                  "occurred_at": "2026-09-05T09:01:10Z"
                }
              ]
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/feedback', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "items": [
                  {
                    "phone": "+44 7911 012345",
                    "outcome": "undelivered",
                    "failure_class": "transient"
                  },
                  {
                    "phone": "(415) 555-0142",
                    "country": "US",
                    "outcome": "delivered",
                    "occurred_at": "2026-09-05T09:00:00Z"
                  },
                  {
                    "phone": "(415) 555-0142",
                    "country": "US",
                    "outcome": "verified",
                    "occurred_at": "2026-09-05T09:01:10Z"
                  }
                ]
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/phone/feedback',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'items': [
                        {
                            'phone': '+44 7911 012345',
                            'outcome': 'undelivered',
                            'failure_class': 'transient'
                        },
                        {
                            'phone': '(415) 555-0142',
                            'country': 'US',
                            'outcome': 'delivered',
                            'occurred_at': '2026-09-05T09:00:00Z'
                        },
                        {
                            'phone': '(415) 555-0142',
                            'country': 'US',
                            'outcome': 'verified',
                            'occurred_at': '2026-09-05T09:01:10Z'
                        }
                    ]
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/feedback');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'items' => [
                        [
                            'phone' => '+44 7911 012345',
                            'outcome' => 'undelivered',
                            'failure_class' => 'transient'
                        ],
                        [
                            'phone' => '(415) 555-0142',
                            'country' => 'US',
                            'outcome' => 'delivered',
                            'occurred_at' => '2026-09-05T09:00:00Z'
                        ],
                        [
                            'phone' => '(415) 555-0142',
                            'country' => 'US',
                            'outcome' => 'verified',
                            'occurred_at' => '2026-09-05T09:01:10Z'
                        ]
                    ]
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.report_phone_feedback([
                {
                    'phone': '+44 7911 012345',
                    'outcome': 'undelivered',
                    'failure_class': 'transient'
                },
                {
                    'phone': '(415) 555-0142',
                    'country': 'US',
                    'outcome': 'delivered',
                    'occurred_at': '2026-09-05T09:00:00Z'
                },
                {
                    'phone': '(415) 555-0142',
                    'country': 'US',
                    'outcome': 'verified',
                    'occurred_at': '2026-09-05T09:01:10Z'
                }
            ])
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.reportPhoneFeedback([
                {
                    phone: '+44 7911 012345',
                    outcome: 'undelivered',
                    failure_class: 'transient'
                },
                {
                    phone: '(415) 555-0142',
                    country: 'US',
                    outcome: 'delivered',
                    occurred_at: '2026-09-05T09:00:00Z'
                },
                {
                    phone: '(415) 555-0142',
                    country: 'US',
                    outcome: 'verified',
                    occurred_at: '2026-09-05T09:01:10Z'
                }
            ]);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->reportPhoneFeedback([
                [
                    'phone' => '+44 7911 012345',
                    'outcome' => 'undelivered',
                    'failure_class' => 'transient'
                ],
                [
                    'phone' => '(415) 555-0142',
                    'country' => 'US',
                    'outcome' => 'delivered',
                    'occurred_at' => '2026-09-05T09:00:00Z'
                ],
                [
                    'phone' => '(415) 555-0142',
                    'country' => 'US',
                    'outcome' => 'verified',
                    'occurred_at' => '2026-09-05T09:01:10Z'
                ]
            ]);
  /api/v1/phone/feedback/summary:
    get:
      operationId: phone-feedback-summary
      tags:
        - Phone
      summary: 'Reported SMS outcomes'
      description: |
        The last 90 days of outcomes you reported: totals per outcome, the failed deliveries split by failure class in `undelivered_by_class`, how many distinct numbers they cover, `verified_share` (of the distinct numbers you reported delivered or verified, the share you reported verified), and the ten blocks (region plus the first six digits of the national number) with the most failed deliveries about the number, with their delivered, abused and verified counts and their own `verified_share` alongside. A bad list or a wholesaler's range shows up here first, and so does pumped traffic: delivered, never verified, in a block whose share sits far under yours. Your own sending problems — a lapsed campaign registration, a carrier filter — show under `sender_side` instead of in the blocks.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - RATE_LIMITED
      responses:
        200:
          description: 'The summary.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/PhoneFeedbackSummary'
              example:
                success: true
                data:
                  window_days: 90
                  total: 3120
                  numbers: 2870
                  outcomes:
                    delivered: 2988
                    undelivered: 119
                    abused: 11
                    verified: 2410
                    opted_out: 2
                  verified_share: 0.812
                  undelivered_by_class:
                    permanent: 9
                    transient: 64
                    not_sms_capable: 3
                    sender_side: 38
                    unclassified: 5
                  top_undelivered_blocks:
                    -
                      country: US
                      block: '201201'
                      undelivered: 38
                      delivered: 2
                      abused: 4
                      verified: 0
                      verified_share: 0
                    -
                      country: GB
                      block: '770090'
                      undelivered: 12
                      delivered: 0
                      abused: 0
                      verified: 0
                      verified_share: null
        401:
          $ref: '#/components/responses/Unauthenticated'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/phone/feedback/summary \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/feedback/summary', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/phone/feedback/summary',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/feedback/summary');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.phone_feedback_summary()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.phoneFeedbackSummary();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->phoneFeedbackSummary();
  /api/v1/phone/suppressions:
    get:
      operationId: list-phone-suppressions
      tags:
        - Phone
      summary: 'List the phone suppression list'
      description: |
        The account's phone suppression list, newest first, 100 entries per page. A fresh lookup that finds a geographic number in a block the regulator does not allocate adds it with source `auto` and its stored answer; the account's own SMS outcomes add a number with source `feedback` after an abuse report, one permanent failure, two failures about the number with no later delivery (reasons `abuse` and `undelivered`), or an opt-out (reason `opted_out`); imports arrive with source `imported`. Batch, bulk and monitor runs answer suppressed numbers free from the stored answer, or with reason `suppressed` where there is none; a single lookup always runs fresh. A valid answer, from any lookup, removes only an `auto` entry: an `undelivered` entry leaves when you report a delivery or a verification, and an `abuse`, `opted_out` or `imported` one only when you remove it, so nobody typing a number into your form can take it off the list. An abuse report or opt-out about a number you imported is recorded on that entry, which keeps source `imported`; an abuse or opt-out entry you removed comes back only with a new abuse report or opt-out. Imports, opt-outs and abuse entries never go stale: they are served for as long as they stand. An `auto` or `undelivered` entry untouched for 90 days is re-checked instead of trusted: the fresh answer removes a stale `auto` entry when it comes back valid, and a stale `undelivered` entry stays listed, renewed by a new failure report. A single lookup of a number held as `opted_out` answers `blocked_by: opted_out` under the default SMS policy (see `block_opted_out`).

        Every filter is optional and they combine. `value` looks one number up: it is read to E.164 the way the list stores it, so `07911 012345` with `country=GB` finds the entry filed as `+447911012345`. A number that is not a valid subscriber number matches nothing. An unparseable date or an out-of-range `per_page` is a `422`, not a silently unfiltered page.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      parameters:
        -
          name: value
          in: query
          required: false
          schema:
            type: string
            maxLength: 500
          example: '07911 012345'
          description: 'One number, in any notation. Read to E.164 before it is matched.'
        -
          name: country
          in: query
          required: false
          schema:
            type: string
            minLength: 2
            maxLength: 2
          example: GB
          description: 'The region `value` is read against when it carries no calling code.'
        -
          name: source
          in: query
          required: false
          schema:
            type: string
            enum:
              - auto
              - imported
              - feedback
          example: imported
          description: 'Only entries this source added.'
        -
          name: reason
          in: query
          required: false
          schema:
            type: string
            enum:
              - unassigned_block
              - undelivered
              - abuse
              - opted_out
          example: undelivered
          description: 'Only entries carrying this reason.'
        -
          name: since
          in: query
          required: false
          schema:
            type: string
            format: date
          example: '2026-08-01'
          description: 'Only entries added on or after this date.'
        -
          name: until
          in: query
          required: false
          schema:
            type: string
            format: date
          example: '2026-09-01'
          description: 'Only entries added on or before this date. A bare date means the whole of that day.'
        -
          name: page
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            default: 1
          example: 1
          description: 'The page to return.'
        -
          name: per_page
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
          example: 100
          description: 'Entries per page, up to 500.'
      responses:
        200:
          description: 'One page of entries.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      suppressions:
                        type: array
                        items:
                          $ref: '#/components/schemas/PhoneSuppression'
                  meta:
                    type: object
                    properties:
                      page:
                        type: integer
                      per_page:
                        type: integer
                      total:
                        type: integer
              example:
                success: true
                data:
                  suppressions:
                    -
                      id: 311
                      phone: '+14152010142'
                      reason: unassigned_block
                      source: auto
                      added_at: '2026-09-05T08:30:00+00:00'
                    -
                      id: 310
                      phone: '+447911012345'
                      reason: undelivered
                      source: feedback
                      added_at: '2026-09-04T17:02:11+00:00'
                meta:
                  page: 1
                  per_page: 100
                  total: 2
        401:
          $ref: '#/components/responses/Unauthenticated'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/phone/suppressions \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/suppressions', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/phone/suppressions',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/suppressions');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.list_phone_suppressions()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.listPhoneSuppressions();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->listPhoneSuppressions();
    post:
      operationId: add-phone-suppressions
      tags:
        - Phone
      summary: 'Import known-dead numbers'
      description: |
        Adds up to 10,000 numbers per request to the phone suppression list — an SMS provider's failure export, a hand-kept blocklist. Each item is a number in any notation or an object naming its own `country`; `country` at the top level applies to the rest. Numbers are normalised to E.164 first; ones that are not valid subscriber numbers (including national notation with no country) are counted as `invalid`. A number already on the list is counted as `already_suppressed`: it keeps its reason and any stored answer, and becomes an import too, so no later lookup or report clears it — it leaves only when you remove it. A stored answer is served for 90 days after the entry was last renewed; after that it is dropped, with an `unassigned_block` reason, and the import answers `suppressed`, because nothing looks an import up again. An abuse report or opt-out reported about it later is recorded on the entry (reason `abuse` or `opted_out`, still source `imported`). An import never goes stale, so importing a number again is never needed. Imported entries are honoured from day one at no credit cost: batch, bulk and monitor runs answer them with reason `suppressed`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - items
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 10000
                  items:
                    type:
                      - string
                      - object
                    properties:
                      phone:
                        type: string
                        maxLength: 64
                      country:
                        type:
                          - string
                          - 'null'
                        description: 'An ISO 3166-1 alpha-2 region code for this number.'
                  description: '1 to 10,000 numbers, each a string of at most 64 characters or an object with its own country.'
                country:
                  type:
                    - string
                    - 'null'
                  description: 'The region for items written without a calling code that name none of their own.'
            example:
              items:
                - '+1 415 201 0142'
                -
                  phone: '07911 012345'
                  country: GB
      responses:
        200:
          description: 'How many entries were added, already present, or not valid numbers.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      added:
                        type: integer
                      already_suppressed:
                        type: integer
                      invalid:
                        type: integer
              example:
                success: true
                data:
                  added: 2
                  already_suppressed: 0
                  invalid: 0
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/phone/suppressions \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "items": [
                "+1 415 201 0142",
                {
                  "phone": "07911 012345",
                  "country": "GB"
                }
              ]
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/suppressions', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "items": [
                  "+1 415 201 0142",
                  {
                    "phone": "07911 012345",
                    "country": "GB"
                  }
                ]
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/phone/suppressions',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'items': [
                        '+1 415 201 0142',
                        {
                            'phone': '07911 012345',
                            'country': 'GB'
                        }
                    ]
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/suppressions');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'items' => [
                        '+1 415 201 0142',
                        [
                            'phone' => '07911 012345',
                            'country' => 'GB'
                        ]
                    ]
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.add_phone_suppressions([
                '+1 415 201 0142',
                {
                    'phone': '07911 012345',
                    'country': 'GB'
                }
            ])
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.addPhoneSuppressions([
                '+1 415 201 0142',
                {
                    phone: '07911 012345',
                    country: 'GB'
                }
            ]);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->addPhoneSuppressions([
                '+1 415 201 0142',
                [
                    'phone' => '07911 012345',
                    'country' => 'GB'
                ]
            ]);
    delete:
      operationId: remove-phone-suppressions
      tags:
        - Phone
      summary: 'Remove numbers from the phone suppression list'
      description: |
        Removes up to 1,000 numbers per request by the number itself, for a caller that knows the number but not the id it was filed under. Each value is read to E.164 first, against `country` when it carries no calling code, so `07911 012345` with `country: "GB"` removes the entry filed as `+447911012345`.

        The answer names every number it was given, spelled the way you sent it: `removed` are the ones that were on the list and no longer are, `not_found` the ones that were not on it — including a number that is not a valid subscriber number, and one that is on another account's list.

        The cap is 1,000 rather than the import's 10,000 because the answer names every value back: a request over the cap is a `422` and removes nothing. Removing an entry means the number is looked up fresh the next time a batch, bulk or monitor run meets it.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - values
              properties:
                values:
                  type: array
                  minItems: 1
                  maxItems: 1000
                  items:
                    type: string
                    maxLength: 64
                  description: '1 to 1,000 numbers, each in any notation.'
                country:
                  type:
                    - string
                    - 'null'
                  description: 'The region values written without a calling code are read against.'
            example:
              values:
                - '07911 012345'
                - '+14152010142'
              country: GB
      responses:
        200:
          description: 'Which of the numbers were on the list and which were not.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      removed:
                        type: array
                        items:
                          type: string
                        description: 'The numbers that were on the list, as you sent them.'
                      not_found:
                        type: array
                        items:
                          type: string
                        description: 'The numbers that were not on the list, as you sent them.'
              example:
                success: true
                data:
                  removed:
                    - '07911 012345'
                  not_found:
                    - '+14152010142'
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X DELETE https://spaw.co/api/v1/phone/suppressions \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "values": [
                "07911 012345",
                "+14152010142"
              ],
              "country": "GB"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/suppressions', {
              method: 'DELETE',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "values": [
                  "07911 012345",
                  "+14152010142"
                ],
                "country": "GB"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.delete(
                'https://spaw.co/api/v1/phone/suppressions',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'values': [
                        '07911 012345',
                        '+14152010142'
                    ],
                    'country': 'GB'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/suppressions');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'DELETE',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'values' => [
                        '07911 012345',
                        '+14152010142'
                    ],
                    'country' => 'GB'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.remove_phone_suppressions([
                '07911 012345',
                '+14152010142'
            ], country='GB')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.removePhoneSuppressions([
                '07911 012345',
                '+14152010142'
            ], {
                country: 'GB'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->removePhoneSuppressions([
                '07911 012345',
                '+14152010142'
            ], country: 'GB');
  '/api/v1/phone/suppressions/{suppressionId}':
    delete:
      operationId: remove-phone-suppression
      tags:
        - Phone
      summary: 'Remove a phone suppression entry'
      description: |
        Deletes one entry so the number is looked up fresh the next time a batch, bulk or monitor run meets it. An entry that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: suppressionId
          in: path
          required: true
          schema:
            type: integer
          example: 311
          description: "The entry's id from the list endpoint."
      responses:
        200:
          description: 'The entry was removed.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
              example:
                success: true
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X DELETE https://spaw.co/api/v1/phone/suppressions/311 \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/suppressions/311', {
              method: 'DELETE',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.delete(
                'https://spaw.co/api/v1/phone/suppressions/311',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/suppressions/311');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'DELETE',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.remove_phone_suppression(311)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.removePhoneSuppression(311);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->removePhoneSuppression(311);
  /api/v1/phone/monitors:
    get:
      operationId: list-phone-monitors
      tags:
        - Phone
      summary: "List the account's phone monitors"
      description: |
        The saved number lists this account re-checks on a schedule, newest first, 100 per page. Each monitor carries the summary of its last run: how many numbers were valid, invalid, in a block nobody holds or unreachable, how many changed status, how many decayed (valid last time, not any more), how many were newly flagged (still valid, but changed), the credits the run spent, and `stopped_reason` when it did not finish cleanly: `insufficient_credits` or `key_spend_cap` where the run stopped part-way, `run_failed` where it died outright, and `run_failed_retrying` where it died and the schedule has been pulled in to check again — `retry_at` in the same block says when that becomes due, and the hourly sweep picks it up at or after that, so between 45 minutes and about an hour and three quarters later. A summary carrying a `stopped_reason` of either failure kind has NO counts in it at all: the run produced none, so read a missing count as unknown rather than as zero. The numbers and their statuses are on the single-monitor endpoint.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - RATE_LIMITED
      parameters:
        -
          name: page
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            default: 1
          example: 1
          description: 'The page to return.'
      responses:
        200:
          description: 'One page of monitors.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitors:
                        type: array
                        items:
                          $ref: '#/components/schemas/PhoneMonitor'
                  meta:
                    type: object
                    properties:
                      page:
                        type: integer
                      per_page:
                        type: integer
                      total:
                        type: integer
                      retention_days:
                        type: integer
                        description: 'How long a list nobody runs is kept before it is deleted.'
              example:
                success: true
                data:
                  monitors:
                    -
                      id: 7
                      name: 'SMS audience'
                      cadence: monthly
                      number_count: 2
                      country: GB
                      live_check: false
                      next_run_at: '2026-10-05T08:00:00+00:00'
                      last_run_at: '2026-09-05T08:00:12+00:00'
                      last_summary:
                        total: 2
                        valid: 1
                        invalid: 0
                        unassigned: 1
                        unreachable: 0
                        suppressed: 0
                        changed: 0
                        decayed: 0
                        credits_used: 1
                        stopped_reason: null
                      created_at: '2026-08-27T08:00:00+00:00'
                meta:
                  page: 1
                  per_page: 100
                  total: 1
                  retention_days: 365
        401:
          $ref: '#/components/responses/Unauthenticated'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/phone/monitors \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/monitors', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/phone/monitors',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/monitors');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.list_phone_monitors()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.listPhoneMonitors();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->listPhoneMonitors();
    post:
      operationId: create-phone-monitor
      tags:
        - Phone
      summary: 'Monitor a phone list on a schedule'
      description: |
        Saves up to 500 numbers and re-checks them every week or every month. The first run starts right away and only sets the baseline; from the next run on, every number that was valid last time and is now invalid, in a block nobody holds or unreachable counts as decayed and is reported to the account's email; a number you suppressed yourself (an import, your own failure or abuse reports, an opt-out) answers `suppressed` and is not decay. A number that still answers valid but changed is flagged and reported in a second email: an SMS-receiving website started listing it, three or more accounts reported abuse, its risk rose to high, or its line type changed — and with the live check on, it was ported or another network serves it (see PhoneMonitorFlag; this is not SIM-swap detection). Runs honour the suppression list and the 7-day repeat cache. With `live_check` true, runs ask for the live carrier check whenever the service has it enabled, so decay also covers handsets that stopped answering. There is no daily cadence: most regulator tables behind a run refresh weekly (NANPA's and the SMS-receiving lists daily), a weekly run already catches a change within days, and a daily live check would pay for the network seven times a week.

        Answers `201` with the monitor. The baseline run is queued, not finished: poll the monitor for `last_run_at` and `last_summary`.

        With a `webhook_url`, a finished run is POSTed there as `{ "event": "phone_monitor.run", "monitor": { "id", "name", "cadence", "last_run_at", "next_run_at" }, "summary": { … the same block as `last_summary` … }, "changes": [ … ], "flags": { … } }`, where `changes` names the numbers the two alert emails name — decayed and newly flagged, in list order, each once — and `flags` maps each newly flagged number to the flags it raised (`{}` when none did). The `X-Spaw-Signature-V2` header carries `t={unix seconds},v2={lowercase hex}`, where the hex is the HMAC-SHA256 of `v2:{t}:{raw body}` keyed with `webhook_secret`, which this response returns once; a receiver should refuse anything whose `t` is more than five minutes from its own clock, since a signature over the body alone would let a captured delivery be replayed at it forever. The original `X-Spaw-Signature`, that HMAC of the body alone, is sent beside it until 1 March 2027. Connection errors and 5xx answers are retried twice with a short backoff, three attempts in all, and the outcome is readable afterwards as `webhook_status` and `webhook_detail`. A URL that resolves onto a private network is never posted to.
      x-spaw-auth: bearer
      x-spaw-billing: "Creation is free. Each run bills every number like a single lookup: 1 credit for a fresh valid answer, plus the live-check credits when the check answered; invalid, unassigned, suppressed and 7-day repeats are free, the base credit is shared with any other lookup of the number in the last 7 days, and a check the account paid for in the last 7 days is free while the network's answer is held (a day)."
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
                - numbers
                - cadence
              properties:
                name:
                  type: string
                  maxLength: 100
                  description: 'A label for the dashboard and the decay alerts.'
                numbers:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type: string
                    maxLength: 64
                  description: '1 to 500 numbers in any notation; each item at most 64 characters. Each is saved in E.164, read against `country` where it has no calling code, so two spellings of one number are one entry and the limit counts distinct numbers; an entry that does not parse is saved as typed and answers invalid on every run.'
                cadence:
                  type: string
                  enum:
                    - weekly
                    - monthly
                  description: "How often the list is re-checked. There is no daily option: most regulator tables refresh weekly (NANPA's and the SMS-receiving lists daily), a weekly run already catches a number that stopped being valid or started being listed within days, and a daily live check would pay for the network seven times a week."
                country:
                  type:
                    - string
                    - 'null'
                  description: 'The region for numbers written without a calling code.'
                live_check:
                  type: boolean
                  default: false
                  description: 'Ask for the live carrier check on every run where the service has it enabled.'
                webhook_url:
                  type:
                    - string
                    - 'null'
                  maxLength: 2048
                  format: uri
                  description: 'An https URL every reported run is POSTed to. The signing secret is returned once, in this response, and never again.'
                webhook_events:
                  type: string
                  enum:
                    - changes
                    - every_run
                  default: changes
                  description: 'When to post. `changes` posts only when the run found something new — decay, a status change, a newly flagged entry — or stopped early; `every_run` posts after every run, which also says the monitor is still running.'
            example:
              name: 'SMS audience'
              numbers:
                - '+44 7911 012345'
                - '020 3013 0018'
              cadence: monthly
              country: GB
      responses:
        201:
          description: 'The monitor was saved and its baseline run queued.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        allOf:
                          -
                            $ref: '#/components/schemas/PhoneMonitor'
                          -
                            type: object
                            properties:
                              webhook_secret:
                                type:
                                  - string
                                  - 'null'
                                description: 'The webhook signing secret, shown once here and never readable again. Null when the monitor has no webhook.'
              example:
                success: true
                data:
                  monitor:
                    id: 7
                    name: 'SMS audience'
                    cadence: monthly
                    number_count: 2
                    country: GB
                    live_check: false
                    next_run_at: '2026-10-05T10:12:44+00:00'
                    last_run_at: null
                    last_summary: null
                    created_at: '2026-09-05T10:12:44+00:00'
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/phone/monitors \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "name": "SMS audience",
              "numbers": [
                "+44 7911 012345",
                "020 3013 0018"
              ],
              "cadence": "monthly",
              "country": "GB"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/monitors', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "name": "SMS audience",
                "numbers": [
                  "+44 7911 012345",
                  "020 3013 0018"
                ],
                "cadence": "monthly",
                "country": "GB"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/phone/monitors',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'name': 'SMS audience',
                    'numbers': [
                        '+44 7911 012345',
                        '020 3013 0018'
                    ],
                    'cadence': 'monthly',
                    'country': 'GB'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/monitors');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'name' => 'SMS audience',
                    'numbers' => [
                        '+44 7911 012345',
                        '020 3013 0018'
                    ],
                    'cadence' => 'monthly',
                    'country' => 'GB'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.create_phone_monitor(name='SMS audience', numbers=[
                '+44 7911 012345',
                '020 3013 0018'
            ], cadence='monthly', country='GB')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.createPhoneMonitor({
                name: 'SMS audience',
                numbers: [
                    '+44 7911 012345',
                    '020 3013 0018'
                ],
                cadence: 'monthly',
                country: 'GB'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->createPhoneMonitor(name: 'SMS audience', numbers: [
                '+44 7911 012345',
                '020 3013 0018'
            ], cadence: 'monthly', country: 'GB');
  '/api/v1/phone/monitors/{monitorId}':
    get:
      operationId: get-phone-monitor
      tags:
        - Phone
      summary: 'Read a phone monitor and its last results'
      description: |
        The monitor plus `results`: the status of every number from the last run (`valid`, `invalid`, `unassigned`, `unreachable` or `suppressed` — an entry you put on your suppression list yourself, an import, your own failure or abuse reports or an opt-out, which is never counted as decay), keyed by the number as it was saved — its E.164, or the entry as typed where it does not parse — or null until the first run has finished. Beside it, `flags` maps the same numbers to the change flags that run raised (`[]` where nothing changed); it is null until a run has recorded them, which includes a monitor whose last run came before flags existed. A monitor that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
          example: 7
          description: "The monitor's id from the list endpoint."
      responses:
        200:
          description: 'The monitor.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        allOf:
                          -
                            $ref: '#/components/schemas/PhoneMonitor'
                          -
                            type: object
                            properties:
                              results:
                                type:
                                  - object
                                  - 'null'
                                description: "The last run's status per number (valid, invalid, unassigned, unreachable or suppressed), keyed by the number in E.164 (as typed where it does not parse); null until the first run finishes."
                              flags:
                                type:
                                  - object
                                  - 'null'
                                additionalProperties:
                                  type: array
                                  items:
                                    $ref: '#/components/schemas/PhoneMonitorFlag'
                                description: 'The change flags the last run raised per number, keyed like results; [] where nothing changed. Null until a run has recorded them.'
              example:
                success: true
                data:
                  monitor:
                    id: 7
                    name: 'SMS audience'
                    cadence: monthly
                    number_count: 2
                    country: GB
                    live_check: false
                    next_run_at: '2026-10-05T08:00:00+00:00'
                    last_run_at: '2026-09-05T08:00:12+00:00'
                    last_summary:
                      total: 2
                      valid: 1
                      invalid: 0
                      unassigned: 1
                      unreachable: 0
                      suppressed: 0
                      changed: 0
                      decayed: 0
                      newly_flagged: 1
                      credits_used: 1
                      stopped_reason: null
                    created_at: '2026-08-27T08:00:00+00:00'
                    results:
                      '+447911012345': valid
                      '+442030130018': unassigned
                    flags:
                      '+447911012345':
                        - became_disposable
                      '+442030130018': []
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/phone/monitors/7 \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/monitors/7', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/phone/monitors/7',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/monitors/7');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.get_phone_monitor(7)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.getPhoneMonitor(7);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->getPhoneMonitor(7);
    patch:
      operationId: update-phone-monitor
      tags:
        - Phone
      summary: 'Change a monitor'
      description: |
        Changes a saved monitor in place: rename it, move it to another cadence, replace the numbers, or point it at a webhook. Send only the fields you are changing; a body that names none of them answers `422` rather than quietly changing nothing, because that is the shape a misspelled field name takes.

        Replacing `numbers` replaces the list. What the last run recorded about the numbers that stayed is kept, so their next run still compares against the answer they already had; what it recorded about the ones that are gone is deleted with them; and an entry that is new starts with no baseline, so its first run only sets one. Numbers are saved in E.164 exactly as on creation, read against the `country` this call sends or else the monitor's own, so a number sent again in another notation is the same entry and keeps its baseline. Changing `country` alone reads the saved numbers against it again. A rename never touches a stored answer — they are keyed by the entry, never by the list.

        Changing `cadence` neither skips a run nor starts a second one: a run already scheduled sooner than the new interval still happens, and a longer interval applies from the one after it. Nothing is queued by this call — the change applies to the next run, and `POST .../run` is there to have it now.

        Sending a `webhook_url` that differs from the one stored mints a new signing secret and returns it once, here; sending the URL it already has keeps the secret it is signing with, and `null` removes the webhook. A monitor that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: 'Free. The change applies to the next run.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - VALIDATION_FAILED
        - RATE_LIMITED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
          example: 41
          description: "The monitor's id from the list endpoint."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                name:
                  type: string
                  maxLength: 100
                numbers:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type: string
                    maxLength: 64
                cadence:
                  type: string
                  enum:
                    - weekly
                    - monthly
                country:
                  type:
                    - string
                    - 'null'
                  description: 'The region numbers written without a calling code are read against.'
                live_check:
                  type: boolean
                webhook_url:
                  type:
                    - string
                    - 'null'
                  maxLength: 2048
                  format: uri
                webhook_events:
                  type: string
                  enum:
                    - changes
                    - every_run
            example:
              name: 'SMS audience (EU)'
              country: DE
      responses:
        200:
          description: 'The monitor as it now stands.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        allOf:
                          -
                            $ref: '#/components/schemas/PhoneMonitor'
                          -
                            type: object
                            properties:
                              webhook_secret:
                                type: string
                                description: 'Present only when this call pointed the monitor at a URL it was not already posting to. Shown once.'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X PATCH https://spaw.co/api/v1/phone/monitors/41 \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "name": "SMS audience (EU)",
              "country": "DE"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/monitors/41', {
              method: 'PATCH',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "name": "SMS audience (EU)",
                "country": "DE"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.patch(
                'https://spaw.co/api/v1/phone/monitors/41',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'name': 'SMS audience (EU)',
                    'country': 'DE'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/monitors/41');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'PATCH',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'name' => 'SMS audience (EU)',
                    'country' => 'DE'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.update_phone_monitor(41, name='SMS audience (EU)', country='DE')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.updatePhoneMonitor(41, {
                name: 'SMS audience (EU)',
                country: 'DE'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->updatePhoneMonitor(41, [
                'name' => 'SMS audience (EU)',
                'country' => 'DE'
            ]);
    delete:
      operationId: delete-phone-monitor
      tags:
        - Phone
      summary: 'Stop monitoring a phone list'
      description: |
        Removes the monitor and its stored results; nothing further is scheduled. A run that is already queued finishes on its own and bills as usual. A monitor that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
          example: 7
          description: "The monitor's id from the list endpoint."
      responses:
        200:
          description: 'The monitor was removed.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
              example:
                success: true
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X DELETE https://spaw.co/api/v1/phone/monitors/7 \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/monitors/7', {
              method: 'DELETE',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.delete(
                'https://spaw.co/api/v1/phone/monitors/7',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/monitors/7');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'DELETE',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.delete_phone_monitor(7)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.deletePhoneMonitor(7);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->deletePhoneMonitor(7);
  '/api/v1/phone/monitors/{monitorId}/run':
    post:
      operationId: run-phone-monitor
      tags:
        - Phone
      summary: 'Re-check a monitored phone list now'
      description: |
        Queues a run right away instead of waiting for the schedule; when it finishes, the next scheduled run is set one cadence from then. Answers `202` with the monitor as it stands before the run; poll it for the new `last_run_at`, `last_summary` and `results`. A monitor that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: 'The run bills every number like a single lookup; suppressed numbers and 7-day repeats are free.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
          example: 7
          description: "The monitor's id from the list endpoint."
      responses:
        202:
          description: 'The run was queued.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        $ref: '#/components/schemas/PhoneMonitor'
              example:
                success: true
                data:
                  monitor:
                    id: 7
                    name: 'SMS audience'
                    cadence: monthly
                    number_count: 2
                    country: GB
                    live_check: false
                    next_run_at: '2026-10-05T08:00:00+00:00'
                    last_run_at: '2026-09-05T08:00:12+00:00'
                    last_summary:
                      total: 2
                      valid: 1
                      invalid: 0
                      unassigned: 1
                      unreachable: 0
                      suppressed: 0
                      changed: 0
                      decayed: 0
                      credits_used: 1
                      stopped_reason: null
                    created_at: '2026-08-27T08:00:00+00:00'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/phone/monitors/7/run \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/monitors/7/run', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/phone/monitors/7/run',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/monitors/7/run');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.run_phone_monitor(7)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.runPhoneMonitor(7);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->runPhoneMonitor(7);
  /api/v1/phone/policy:
    get:
      operationId: get-phone-policy
      tags:
        - Phone
      summary: "Read the account's SMS policy"
      description: |
        The rules behind `ok_to_send` on every phone lookup this account runs: the countries it sends to, the line types it refuses, the signals that block, the risk-score ceiling, whether a live check must have reached the handset, and whether a single lookup of a number the suppression list holds as opted out is blocked. The policy belongs to the account, so every member reads and changes the same one. An invalid number is always blocked whatever the policy says.

        `ok_to_send` is a fraud and deliverability decision computed from the answer and these rules. It is not consent to message anyone and not a do-not-call scrub; neither is part of the answer.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - RATE_LIMITED
      responses:
        200:
          description: 'The policy in force.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/PhonePolicy'
              example:
                success: true
                data:
                  max_risk_score: 60
                  block_signals:
                    - fictional
                    - disposable
                    - reported_abuse
                    - reported_abuse_widely
                    - premium_rate
                  allowed_countries: null
                  blocked_countries: null
                  blocked_line_types: []
                  require_reachable: false
                  block_opted_out: true
                  is_default: true
                  updated_at: null
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/phone/policy \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/policy', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/phone/policy',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/policy');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.get_phone_policy()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.getPhonePolicy();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->getPhonePolicy();
    put:
      operationId: update-phone-policy
      tags:
        - Phone
      summary: 'Change one or more rules of the SMS policy'
      description: |
        Names only the rules that change; the rest stay as they were, and the change applies to the account's next lookup. `max_risk_score: null` ignores the score, `block_signals: []` blocks on no signal, `allowed_countries: null` sends to any country and `blocked_countries: null` blocks none. Country codes are read upper-case and kept sorted; an empty `allowed_countries` is refused, because it would block every number.

        A country allow-list is the first control against SMS pumping to destinations the business never serves. It is a business choice about where to send, not a verdict that a country's numbers are fraudulent. The country is the number's own, read from its calling code and prefix, so +1 876 is Jamaica rather than the United States; a number with no country (international networks, global freephone) is outside every allow-list.

        `blocked_line_types` refuses the line types named; `fixed_line_or_mobile` can never be one, because it is what the numbering plan answers when it cannot tell a mobile from a landline, which is most of North America. UK 084 and 087 numbers answer `shared_cost`, not `premium_rate`, so a sender that refuses them lists both. `require_reachable: true` passes only a lookup that asked for the live check (`hlr`) and got `reachable: true`: a handset the network could not reach is blocked as `unreachable`, and a lookup with no live answer as `unconfirmed` — while the live check is not enabled on the service, that is every lookup.

        `block_opted_out` (default true) blocks every lookup of one number — the single lookup, the browser endpoint, the dashboard, the MCP tool and the consistency check — when your own suppression list (that of the user whose key or session made the lookup; each member keeps their own, while the policy is the account's) holds the number as opted out (`meta.opted_out: true`), as `blocked_by: opted_out`, checked right after `invalid`. Batch, bulk and monitor runs serve such a number from the list as `suppressed` either way, and test numbers never read the list. An OTP or transactional sender may want it off: an opt-out from marketing does not always forbid a verification code, and the person typing the number may be its owner asking for one again. Off, the number is decided like any other and `meta.opted_out` still reports it. Honouring an opt-out stays the account's obligation; the rule is not a consent record.

        A body naming none of the seven rules is refused with a 422 keyed `policy`, because that is the shape a misspelled key takes.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - RATE_LIMITED
        - VALIDATION_FAILED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                max_risk_score:
                  type:
                    - integer
                    - 'null'
                  minimum: 0
                  maximum: 100
                  description: 'Block when risk_score is above this; null ignores the score.'
                block_signals:
                  type:
                    - array
                    - 'null'
                  maxItems: 18
                  uniqueItems: true
                  items:
                    type: string
                    enum:
                      - fictional
                      - disposable
                      - disposable_stale
                      - reported_abuse
                      - reported_undelivered
                      - reported_abuse_widely
                      - premium_rate
                      - reserved_block
                      - virtual
                      - range_burst
                      - voip
                      - pager
                      - digit_pattern
                      - numbers_per_client
                      - disposable_range
                      - international_network
                      - shared_cost
                      - reported_unwanted_calls
                  description: 'Signals that block whenever they fire, whatever the score.'
                allowed_countries:
                  type:
                    - array
                    - 'null'
                  minItems: 1
                  uniqueItems: true
                  items:
                    type: string
                    pattern: '^[A-Za-z]{2}$'
                  description: 'Send only to numbers of these regions (ISO 3166-1 alpha-2, as the numbering plan knows them); null sends to any.'
                blocked_countries:
                  type:
                    - array
                    - 'null'
                  uniqueItems: true
                  items:
                    type: string
                    pattern: '^[A-Za-z]{2}$'
                  description: 'Never send to numbers of these regions; null or an empty list blocks none.'
                blocked_line_types:
                  type:
                    - array
                    - 'null'
                  uniqueItems: true
                  items:
                    type: string
                    enum:
                      - mobile
                      - fixed_line
                      - voip
                      - toll_free
                      - premium_rate
                      - shared_cost
                      - personal_number
                      - pager
                      - uan
                      - voicemail
                      - unknown
                  description: 'Line types that block. fixed_line_or_mobile can never be blocked.'
                require_reachable:
                  type: boolean
                  description: 'Block unless a live check reached the handset (hlr_checked true and reachable true).'
                block_opted_out:
                  type: boolean
                  description: 'Block a single or browser lookup of a number your suppression list holds as opted out (meta.opted_out true), as blocked_by opted_out. True by default; an OTP or transactional sender may switch it off. Batch, bulk and monitor runs answer such a number suppressed either way.'
            example:
              max_risk_score: 50
              allowed_countries:
                - GB
                - IE
      responses:
        200:
          description: 'The policy now in force.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/PhonePolicy'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X PUT https://spaw.co/api/v1/phone/policy \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "max_risk_score": 50,
              "allowed_countries": [
                "GB",
                "IE"
              ]
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/policy', {
              method: 'PUT',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "max_risk_score": 50,
                "allowed_countries": [
                  "GB",
                  "IE"
                ]
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.put(
                'https://spaw.co/api/v1/phone/policy',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'max_risk_score': 50,
                    'allowed_countries': [
                        'GB',
                        'IE'
                    ]
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/policy');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'PUT',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'max_risk_score' => 50,
                    'allowed_countries' => [
                        'GB',
                        'IE'
                    ]
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.update_phone_policy(max_risk_score=50, allowed_countries=[
                'GB',
                'IE'
            ])
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.updatePhonePolicy({
                maxRiskScore: 50,
                allowedCountries: [
                    'GB',
                    'IE'
                ]
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->updatePhonePolicy([
                'max_risk_score' => 50,
                'allowed_countries' => [
                    'GB',
                    'IE'
                ]
            ]);
    delete:
      operationId: reset-phone-policy
      tags:
        - Phone
      summary: 'Restore the default SMS policy'
      description: 'Puts the published defaults back — block above 60 and on fictional, disposable, reported_abuse, reported_abuse_widely and premium_rate, any country, no line type, no live-check requirement, block opted-out numbers — and forgets when the policy was last changed.'
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - RATE_LIMITED
      responses:
        200:
          description: 'The defaults, in force again.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/PhonePolicy'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X DELETE https://spaw.co/api/v1/phone/policy \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/phone/policy', {
              method: 'DELETE',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.delete(
                'https://spaw.co/api/v1/phone/policy',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/phone/policy');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'DELETE',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.reset_phone_policy()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.resetPhonePolicy();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->resetPhonePolicy();
  /api/v1/ip:
    post:
      operationId: lookup-ip
      tags:
        - IP
      summary: 'Locate and risk-score an IP address'
      description: |
        Locates an IPv4 or IPv6 address with the DB-IP Lite city and ASN databases, applies the network operator's own published location where a geofeed or provider list carries one (`location_source: "operator"`), and flags datacenter, Tor exit, VPN, iCloud Private Relay and corporate-gateway addresses from open and first-party feeds: the Tor Project's exit list and Onionoo relay data, X4BNet's datacenter and VPN lists, the published ranges of AWS, Google Cloud, Microsoft Azure, Oracle Cloud, DigitalOcean, Linode, Vultr, Cloudflare, Fastly and GitHub's Actions runners, the server lists of Mullvad, NordVPN, Private Internet Access, IVPN, AirVPN and the VPN Gate volunteer relays, Apple's relay egress list, and the egress ranges Zscaler publishes for its clouds. `hosting_provider`, `privacy_service` and `corporate_proxy` name the provider when its own feed contains the address. Cloudflare publishes no list of its WARP egress, so WARP is inferred as Cloudflare's own network outside the CDN ranges it does publish, and `privacy_service` then reads `Cloudflare WARP (inferred)`. `is_anonymous` is true when any of Tor, VPN or relay is. `is_crawler` flags the ranges the search, AI and SEO crawlers publish for verification (Googlebot and Google's fetchers, Bingbot, GPTBot, ChatGPT-User, OAI-SearchBot, PerplexityBot, Perplexity-User, DuckDuckBot, AhrefsBot), with `crawler` and `crawler_kind` naming them; a crawler is not a risk, so it carries no weight and, like relay egress and a corporate gateway, never the datacenter weight.

        Country-level facts come with the location: `country_name`, `continent`, `is_eu`, `is_eea`, `privacy_regime` (the comprehensive privacy law where the address is, the region first: ccpa in California, gdpr across the EU and EEA, uk_gdpr, lgpd, …, null when none is known), `sanctions` (the programmes naming the country: `ofac_comprehensive` for Cuba, Iran and North Korea, `ofac_programme` for any country-related OFAC programme; an empty list when none does, null when the country is unknown) and `fatf_list` (`call_for_action` or `increased_monitoring`, as of the FATF's June 2026 plenary) — facts about a place, never a verdict about a person — plus `currency`, `calling_code`, and `timezone` with `timezone_source`: the curated map settles the zone by `region` or `country` where a place keeps one clock, and where it cannot (Texas, most of Canada, Australia, Brazil, Russia) the nearest GeoNames place inside the located country within 250 km does (`nearest_city`); null when neither can. Send `browser_timezone`, the zone the browser reported to your page, and `timezone_matches` says whether it keeps the same clock as the located zone. `network` is the prefix the location answer covers, and `location_confidence` grades the city: low for an anycast, mobile-carrier or satellite address whoever placed it, else high when the operator declared it, otherwise from the measured agreement for that country in the weekly accuracy report. `registry`, `registry_country` and `allocated_at` come from the RIR delegated statistics; a freshly allocated block, or a registry country that disagrees with the geolocation, is worth a second look. `connection_type` says what the address is attached to from positive evidence only — `corporate_proxy`, `hosting`, `satellite`, `mobile`, or `residential` from a dynamic reverse-DNS name — and null never means residential; `is_mobile` and `is_satellite` come from curated, positive-only operator lists, so false means "not listed".

        Two more feeds answer questions the others cannot: `is_unallocated` is true when no regional Internet registry has allocated or assigned the space, from the five RIRs' daily statistics, which is the strongest sign of a spoofed or forged address; `is_anycast` marks published anycast ranges such as CDN edges and the public resolvers. `is_blocklisted` is true when a public threat blocklist names the address's netblock or its operator: Spamhaus DROP, Spamhaus ASN-DROP (matched by the address's ASN) and abuse.ch's Feodo Tracker list of active botnet C2 servers, with `blocklist` naming the list. `is_attack_source` is true when an attack-source list names the address — single hosts seen attacking honeypots and sensors in the last days: Emerging Threats' compromised-hosts list, the CINS Army list and blocklist.de — with `attack_source` naming the list; these name hosts rather than netblocks and churn daily, so they sit apart from the blocklists, weigh 50 and are refreshed hourly. Tor exits are read from both the Tor Project's bulk list and its Onionoo relay data, refreshed hourly, so `is_tor` is evaluated for IPv6 too; a thirty-day exit history answers `tor_last_seen`, the date the address was last seen exiting, and an address that left the list within the last week still carries the `recently_tor` weight. Send `abuse_contact: true` to add the network's abuse mailbox from the registry's RDAP record; it is a network lookup, cached per allocation, so it is only made when asked for. `network_details: true` adds the record's name, organisation, range and dates from the same cached lookup, and `hostname: true` adds the reverse DNS name with `hostname_verified` saying whether it forward-confirms, `hostname_kind` reading the naming convention as `dynamic` or `static`, and `hostname_domain` its registrable domain; a verified name under a crawler operator's domain also proves the crawler. The RFC 8805 geofeeds discovered across the registries every week feed the location override too.

        Your account's own allow and deny lists (`/api/v1/ip/lists`) apply to every lookup the account is billed for: `account_list` names the list and the entry that matched, an allow-list hit answers `risk_score: 0` with a single `allowlisted` signal of weight 0, and a deny-list hit adds `denylisted` 100. `seen_by_you` counts how often this account looked the address up in the seven days before this call and when it first did, under a keyed hash of the address; it is null under privacy mode and for reserved addresses.

        The flags are tri-state: `false` is a checked negative, `null` means the signal was not evaluated (feed not installed, or no feed for that signal covers the address family). Apple's relay egress also appears in the aggregate VPN list, so when Apple's own list says relay and no VPN provider's list names the address, `is_vpn` answers `false`: the first-party declaration outranks the aggregate classification. `risk_score` sums only the signals that were checked — denylisted 100, unallocated 100, blocklist 80, Tor 70, reported_abuse 60, attack_source 50, datacenter 40, reported_abuse_widely 40, recently_tor 40, VPN 30, relay 10, capped at 100; the datacenter weight is skipped when `is_relay`, `is_crawler` or `is_corporate_proxy` is true because relay egress, verified crawlers and corporate gateways sit on infrastructure by design; `reported_abuse` is your own outcome reports for the address inside the window and `reported_abuse_widely` means three or more accounts reported it (see the feedback endpoint) — `risk_signals` lists each contributing signal with the weight applied, and `risk_level` buckets the score with the same thresholds as email: 60 and above high, 30 and above medium. Reserved and private ranges answer `reason: "reserved_range"` with every field null.

        Nothing about an IP lookup is stored: no history row, no log line with the address. The only traces are a keyed seven-day marker that makes a repeat free and the keyed counter behind `seen_by_you`. Send `privacy: true` to skip both; every such call is then a fresh, billable lookup. Send `fields`, a comma-separated list of field names, to have `data` carry only those: unknown names are ignored, an empty list means everything, and `sources` is included only if named.

        The same lookup is available as `GET /api/v1/ip/{ip}`, and `GET /api/v1/ip/me` answers for the address the call came from; both answer with `Cache-Control: private, max-age=300`. IP endpoints have their own rate limit of 50 requests per second per key.
      x-spaw-auth: bearer
      x-spaw-billing: '1 credit per 5 fresh lookups that resolve at least one signal (a location, a network, or a positive threat flag: Tor, datacenter, VPN, relay, blocklist, crawler or corporate proxy): the answered lookup that opens a block costs 1 credit and the next four cost nothing, with `meta.ip_lookups_prepaid` counting what is left. Reserved and private ranges, unallocated space, unknown addresses and 7-day repeats are free and use none of the block. Privacy-mode lookups are always fresh.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - INSUFFICIENT_CREDITS
        - RATE_LIMITED
        - SANDBOX_VALUE_NOT_ALLOWED
        - SANDBOX_UNAVAILABLE
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - ip
              properties:
                ip:
                  type: string
                  description: 'An IPv4 or IPv6 address.'
                privacy:
                  type:
                    - boolean
                    - 'null'
                  description: 'Keep nothing about this lookup, not even the seven-day repeat marker. The call is billed as a fresh lookup.'
                abuse_contact:
                  type:
                    - boolean
                    - 'null'
                  description: "Also look up the network's abuse mailbox in the registry's RDAP record."
                network_details:
                  type:
                    - boolean
                    - 'null'
                  description: "Also read the allocation's RDAP record for the network name, organisation, covered range and registration dates (one registry call, shared with abuse_contact and cached per allocation)."
                hostname:
                  type:
                    - boolean
                    - 'null'
                  description: "Also resolve the reverse DNS name and forward-confirm it; a verified name from a crawler operator's domain sets is_crawler. Two DNS queries, cached six hours per address."
                browser_timezone:
                  type:
                    - string
                    - 'null'
                  maxLength: 64
                  description: 'The IANA zone the browser reported to your page (`Intl.DateTimeFormat().resolvedOptions().timeZone`), compared with the located zone for `timezone_matches`. A zone the server does not know answers 422.'
                fields:
                  type:
                    - string
                    - 'null'
                  maxLength: 1000
                  description: "A comma-separated list of the field names to answer with; `data` then carries only those, in the answer's own order. Unknown names are ignored, an empty list means everything, and `sources` is included only if named. `meta` is never narrowed."
            example:
              ip: 8.8.8.8
      responses:
        200:
          description: 'The location, network, flags and score.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/IpResult'
                  meta:
                    $ref: '#/components/schemas/IpMeta'
              example:
                success: true
                data:
                  ip: 8.8.8.8
                  version: 4
                  reason: null
                  network: 8.8.8.0/24
                  continent: NA
                  country: US
                  country_name: 'United States'
                  is_eu: false
                  is_eea: false
                  privacy_regime: ccpa
                  sanctions: []
                  fatf_list: null
                  region: California
                  city: 'Mountain View'
                  latitude: 37.4056
                  longitude: -122.0775
                  location_source: database
                  location_confidence: low
                  timezone: America/Los_Angeles
                  timezone_source: region
                  timezone_matches: null
                  currency: USD
                  calling_code: 1
                  asn: 15169
                  org: 'Google LLC'
                  asn_type: hosting
                  connection_type: hosting
                  is_mobile: false
                  is_satellite: false
                  registry: arin
                  registry_country: US
                  allocated_at: '2023-12-28'
                  abuse_contact: null
                  network_details: null
                  hostname: null
                  hostname_verified: null
                  hostname_kind: null
                  hostname_domain: null
                  is_datacenter: true
                  hosting_provider: null
                  is_corporate_proxy: false
                  corporate_proxy: null
                  is_crawler: false
                  crawler: null
                  crawler_kind: null
                  is_anycast: true
                  is_unallocated: false
                  is_blocklisted: false
                  blocklist: null
                  is_attack_source: false
                  attack_source: null
                  is_tor: false
                  tor_last_seen: null
                  is_vpn: false
                  is_relay: false
                  privacy_service: null
                  is_anonymous: false
                  account_list: null
                  risk_score: 40
                  risk_level: medium
                  risk_signals:
                    -
                      signal: datacenter
                      weight: 40
                      dataset: datacenter-ranges
                  seen_by_you:
                    count: 0
                    first_seen_at: null
                    window_days: 7
                  sources:
                    -
                      dataset: dbip-city-lite
                      version: 2026-09
                    -
                      dataset: dbip-asn-lite
                      version: 2026-09
                    -
                      dataset: tor-exit-list
                      version: '2026-09-12'
                    -
                      dataset: datacenter-ranges
                      version: '2026-09-12'
                    -
                      dataset: vpn-ranges
                      version: '2026-09-12'
                    -
                      dataset: relay-ranges
                      version: '2026-09-12'
                    -
                      dataset: anycast-ranges
                      version: '2026-09-12'
                    -
                      dataset: rir-allocations
                      version: '2026-09-12'
                    -
                      dataset: threat-blocklists
                      version: '2026-09-12'
                    -
                      dataset: attack-source-lists
                      version: '2026-09-12'
                    -
                      dataset: crawler-ranges
                      version: '2026-09-12'
                    -
                      dataset: corporate-proxy-ranges
                      version: '2026-09-12'
                    -
                      dataset: satellite-asns
                      version: '2026-09-12'
                    -
                      dataset: mobile-carrier-asns
                      version: '2026-09-12'
                    -
                      dataset: tor-exit-history
                      version: '2026-09-12'
                    -
                      dataset: operator-geofeeds
                      version: '2026-09-06'
                    -
                      dataset: compliance-lists
                      version: '2026-09-12'
                meta:
                  credits_used: 1
                  credits_remaining: 8
                  cache_hit: false
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
                  ip_lookups_prepaid: 4
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/ip \
              -H "Authorization: Bearer sk_sandbox_spaw" \
              -H "Content-Type: application/json" \
              -d '{
              "ip": "8.8.8.8"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_sandbox_spaw',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "ip": "8.8.8.8"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/ip',
                headers={'Authorization': 'Bearer sk_sandbox_spaw'},
                json={
                    'ip': '8.8.8.8'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_sandbox_spaw', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'ip' => '8.8.8.8'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_sandbox_spaw')
            result = client.lookup_ip('8.8.8.8')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_sandbox_spaw' });
            const result = await spaw.lookupIp('8.8.8.8');
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_sandbox_spaw');
            $result = $spaw->lookupIp('8.8.8.8');
  '/api/v1/ip/{ip}':
    get:
      operationId: get-ip
      tags:
        - IP
      summary: 'Look up an address by path'
      description: |
        Exactly `POST /api/v1/ip` with the address in the path and the options in the query string, for callers that prefer a plain GET: `GET /api/v1/ip/8.8.8.8?privacy=1`. The response, billing and rate limit are the same; `browser_timezone` and `fields` ride in the query string too. The answer carries `Cache-Control: private, max-age=300`, so a client cache may keep it for five minutes — the feeds behind it change once a day.
      x-spaw-auth: bearer
      x-spaw-billing: 'As `POST /api/v1/ip`.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - INSUFFICIENT_CREDITS
        - RATE_LIMITED
        - SANDBOX_VALUE_NOT_ALLOWED
        - SANDBOX_UNAVAILABLE
      parameters:
        -
          name: ip
          in: path
          required: true
          schema:
            type: string
          example: 8.8.8.8
          description: 'An IPv4 or IPv6 address.'
        -
          name: privacy
          in: query
          required: false
          schema:
            type: boolean
          description: 'Keep nothing about this lookup, not even the seven-day repeat marker.'
        -
          name: abuse_contact
          in: query
          required: false
          schema:
            type: boolean
          description: "Also look up the network's abuse mailbox from the registry's RDAP record."
        -
          name: network_details
          in: query
          required: false
          schema:
            type: boolean
          description: "Also read the allocation's RDAP record for the network name, organisation, range and registration dates."
        -
          name: hostname
          in: query
          required: false
          schema:
            type: boolean
          description: 'Also resolve and forward-confirm the reverse DNS name; a verified crawler name sets is_crawler.'
        -
          name: browser_timezone
          in: query
          required: false
          schema:
            type: string
            maxLength: 64
          description: 'The IANA zone the browser reported to your page, compared with the located zone for timezone_matches; a zone the server does not know answers 422.'
        -
          name: fields
          in: query
          required: false
          schema:
            type: string
            maxLength: 1000
          description: 'A comma-separated list of the fields to answer with; unknown names are ignored and sources is included only if named.'
      responses:
        200:
          description: 'The same body as `POST /api/v1/ip`.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/IpResult'
                  meta:
                    $ref: '#/components/schemas/IpMeta'
              example:
                success: true
                data:
                  ip: 8.8.8.8
                  version: 4
                  reason: null
                  country: US
                  city: 'Mountain View'
                  is_datacenter: true
                  risk_score: 40
                  risk_level: medium
                meta:
                  credits_used: 1
                  credits_remaining: 8
                  cache_hit: false
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
                  ip_lookups_prepaid: 4
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/ip/8.8.8.8 \
              -H "Authorization: Bearer sk_sandbox_spaw"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/8.8.8.8', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_sandbox_spaw',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/ip/8.8.8.8',
                headers={'Authorization': 'Bearer sk_sandbox_spaw'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/8.8.8.8');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_sandbox_spaw'],
            ]);
            $result = json_decode(curl_exec($ch), true);
  /api/v1/ip/me:
    get:
      operationId: get-my-ip
      tags:
        - IP
      summary: 'Look up the address the call came from'
      description: |
        Runs the standard lookup for the address your request arrived from, as seen through the trusted proxy in front of the service. Useful from a server to confirm what the outside world sees, and from scripts that need their own egress classified. A reserved address, such as a call from the same machine, answers `reason: "reserved_range"` free of charge. `browser_timezone` and `fields` ride in the query string, and the answer carries `Cache-Control: private, max-age=300`.
      x-spaw-auth: bearer
      x-spaw-billing: 'As `POST /api/v1/ip`.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - INSUFFICIENT_CREDITS
        - RATE_LIMITED
      parameters:
        -
          name: privacy
          in: query
          required: false
          schema:
            type: boolean
          description: 'Keep nothing about this lookup, not even the seven-day repeat marker.'
        -
          name: abuse_contact
          in: query
          required: false
          schema:
            type: boolean
          description: "Also look up the network's abuse mailbox from the registry's RDAP record."
        -
          name: network_details
          in: query
          required: false
          schema:
            type: boolean
          description: "Also read the allocation's RDAP record for the network name, organisation, range and registration dates."
        -
          name: hostname
          in: query
          required: false
          schema:
            type: boolean
          description: 'Also resolve and forward-confirm the reverse DNS name; a verified crawler name sets is_crawler.'
        -
          name: browser_timezone
          in: query
          required: false
          schema:
            type: string
            maxLength: 64
          description: 'The IANA zone the browser reported to your page, compared with the located zone for timezone_matches; a zone the server does not know answers 422.'
        -
          name: fields
          in: query
          required: false
          schema:
            type: string
            maxLength: 1000
          description: 'A comma-separated list of the fields to answer with; unknown names are ignored and sources is included only if named.'
      responses:
        200:
          description: 'The same body as `POST /api/v1/ip`, for the calling address.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/IpResult'
                  meta:
                    $ref: '#/components/schemas/IpMeta'
              example:
                success: true
                data:
                  ip: 203.0.113.9
                  version: 4
                  reason: null
                  country: DE
                  city: Berlin
                  is_datacenter: false
                  risk_score: 0
                  risk_level: low
                meta:
                  credits_used: 0
                  credits_remaining: 8
                  cache_hit: false
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
                  ip_lookups_prepaid: 3
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/ip/me \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/me', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/ip/me',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/me');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.lookup_my_ip()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.lookupMyIp();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->lookupMyIp();
  /api/v1/ip/batch:
    post:
      operationId: lookup-ip-batch
      tags:
        - IP
      summary: 'Look up up to 1,000 addresses in one call'
      description: |
        Runs the single-address lookup for every item, in input order, under the exact single-lookup billing rules. Every answer comes from local databases and compiled feeds, so a full batch of 1,000 takes well under a second. `data.results[]` mirrors the single-address response per item and each item's `meta` carries `credits_used`, `cache_hit` and `ip_lookups_prepaid`. One credit pays for five answered addresses, so most items cost 0: an item that opens a block costs 1 and leaves 4 prepaid, and the lookups the account already held prepaid are used first.

        Repeats inside the list are looked up once and answer as cache hits. The list is walked in order and stops where the balance would run out: running out of credits mid-batch returns the paid partial results with `meta.stopped_reason: "insufficient_credits"` and `processed` lower than `requested`, never discarding billed work. Only a batch whose first lookup is refused answers the typed `402`. A key with a daily credit cap stops the list the same way once the cap is spent and the lookups its last credit bought are used up: the addresses already paid for come back with `meta.stopped_reason: "key_spend_cap"`, and a call that starts with the cap already spent answers `429 KEY_SPEND_CAP_REACHED` and runs nothing. An item that is not an IP address fails validation for the whole request.

        A batch counts as one request against the rate limit. `privacy` applies to every address in the list.

        Every item carries `index`, its zero-based position in the list you sent, and `input`, the address exactly as you sent it — the answer's `ip` is its canonical form. A batch stopped early by `stopped_reason` answers only a prefix of the list, so those two are what line an answer up with the row it came from.
      x-spaw-auth: bearer
      x-spaw-billing: 'Each address bills like a single lookup — 1 credit per 5 fresh answered lookups, prepaid ones used first; reserved and private ranges, unallocated space, unknown addresses and 7-day repeats free — and the call stops cleanly where the balance ends.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - INSUFFICIENT_CREDITS
        - KEY_SPEND_CAP_REACHED
        - RATE_LIMITED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - ips
              properties:
                ips:
                  type: array
                  minItems: 1
                  maxItems: 1000
                  items:
                    type: string
                  description: '1 to 1,000 IPv4 or IPv6 addresses.'
                privacy:
                  type:
                    - boolean
                    - 'null'
                  description: 'Keep nothing about these lookups; every address is billed as a fresh lookup.'
            example:
              ips:
                - 8.8.8.8
                - 103.124.165.2
                - 192.168.1.1
      responses:
        200:
          description: 'One result per processed address, plus the batch totals.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IpBatchResponse'
              example:
                success: true
                data:
                  results:
                    -
                      index: 0
                      input: 8.8.8.8
                      data:
                        ip: 8.8.8.8
                        version: 4
                        reason: null
                        country: US
                        is_datacenter: true
                        risk_score: 40
                        risk_level: medium
                      meta:
                        credits_used: 1
                        cache_hit: false
                        ip_lookups_prepaid: 4
                    -
                      index: 1
                      input: 103.124.165.2
                      data:
                        ip: 103.124.165.2
                        version: 4
                        reason: null
                        country: AL
                        is_vpn: true
                        privacy_service: Mullvad
                        risk_score: 70
                        risk_level: high
                      meta:
                        credits_used: 0
                        cache_hit: false
                        ip_lookups_prepaid: 3
                    -
                      index: 2
                      input: 192.168.1.1
                      data:
                        ip: 192.168.1.1
                        version: 4
                        reason: reserved_range
                        country: null
                        risk_score: null
                        risk_level: null
                      meta:
                        credits_used: 0
                        cache_hit: false
                        ip_lookups_prepaid: 3
                meta:
                  requested: 3
                  processed: 3
                  credits_used: 1
                  credits_remaining: 8
                  ip_lookups_prepaid: 3
                  stopped_reason: null
                  request_id: req_01m1kgdrtqdvwnks99vfgx2rcw
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/ip/batch \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "ips": [
                "8.8.8.8",
                "103.124.165.2",
                "192.168.1.1"
              ]
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/batch', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "ips": [
                  "8.8.8.8",
                  "103.124.165.2",
                  "192.168.1.1"
                ]
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/ip/batch',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'ips': [
                        '8.8.8.8',
                        '103.124.165.2',
                        '192.168.1.1'
                    ]
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/batch');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'ips' => [
                        '8.8.8.8',
                        '103.124.165.2',
                        '192.168.1.1'
                    ]
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.lookup_ips([
                '8.8.8.8',
                '103.124.165.2',
                '192.168.1.1'
            ])
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.lookupIps([
                '8.8.8.8',
                '103.124.165.2',
                '192.168.1.1'
            ]);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->lookupIps([
                '8.8.8.8',
                '103.124.165.2',
                '192.168.1.1'
            ]);
  /api/v1/ip/public:
    post:
      operationId: lookup-ip-public
      tags:
        - IP
      summary: 'Look up an address from the browser with a publishable key'
      description: |
        The browser counterpart of `POST /api/v1/ip`, for localising a page or screening a form on the client without exposing a secret key. It is authenticated by a publishable `pk_` key in the body plus the browser's `Origin` header, which must match one of the domains the key is locked to. A missing `Origin` is rejected on purpose: servers use a secret key. Leave `ip` out to look up the visitor's own address, which is the usual case. Send `browser_timezone`, the zone the page read from the browser (`Intl.DateTimeFormat().resolvedOptions().timeZone`), to have it compared with the located zone in `timezone_matches`, and `fields` to receive only the fields the page needs, so nothing else reaches the visitor's browser.

        The lookup bills the key's owner under the normal rules and answers `{ "success", "data" }` with **no meta block**, so page visitors never see the owner's balance. Because the key sits in page source, give it a daily credit cap in the dashboard: once the cap is spent the endpoint answers `429 KEY_SPEND_CAP_REACHED` until the next day. Free answers (reserved ranges, cache hits) never count toward the cap. A key that carries a Cloudflare Turnstile pair requires a confirmed `turnstile_token` and answers `403 TURNSTILE_FAILED` without one, before any credit is spent.

        Throttled at 20 requests per minute per IP.
      security: []
      x-spaw-auth: publishable-key
      x-spaw-billing: "Bills the key owner like a single lookup; capped by the key's daily credit cap when one is set."
      x-spaw-errors:
        - INVALID_PUBLISHABLE_KEY
        - ORIGIN_NOT_ALLOWED
        - TURNSTILE_FAILED
        - KEY_SPEND_CAP_REACHED
        - VALIDATION_FAILED
        - INSUFFICIENT_CREDITS
        - RATE_LIMITED
      parameters:
        -
          name: Origin
          in: header
          required: true
          schema:
            type: string
          example: 'https://www.example.com'
          description: "Sent by browsers automatically. Its host must be on the key's allowed-domain list."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - key
              properties:
                key:
                  type: string
                  maxLength: 64
                  description: 'A publishable key, which starts with `pk_`.'
                ip:
                  type:
                    - string
                    - 'null'
                  description: "The address to look up. Omit it for the visitor's own address."
                turnstile_token:
                  type:
                    - string
                    - 'null'
                  maxLength: 2048
                  description: 'Required when the key carries a Turnstile pair. A single-use token from the Turnstile widget on the page.'
                browser_timezone:
                  type:
                    - string
                    - 'null'
                  maxLength: 64
                  description: 'The IANA zone the browser reported, compared with the located zone for timezone_matches; a zone the server does not know answers 422.'
                fields:
                  type:
                    - string
                    - 'null'
                  maxLength: 1000
                  description: 'A comma-separated list of the fields to answer with; unknown names are ignored and sources is included only if named.'
            example:
              key: pk_live_…
      responses:
        200:
          description: 'The same fields as `POST /api/v1/ip`, without a meta block.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/IpResult'
              example:
                success: true
                data:
                  ip: 203.0.113.9
                  version: 4
                  reason: null
                  country: DE
                  country_name: Germany
                  is_eu: true
                  city: Berlin
                  timezone: Europe/Berlin
                  currency: EUR
                  is_anonymous: false
                  risk_score: 0
                  risk_level: low
        401:
          description: 'The publishable key does not exist or was revoked.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: INVALID_PUBLISHABLE_KEY
                  message: 'That publishable key does not exist or was revoked.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        402:
          $ref: '#/components/responses/InsufficientCredits'
        403:
          description: "The page's origin is not on the key's allowed-domain list (`ORIGIN_NOT_ALLOWED`), or the key requires a Turnstile token that was missing or not confirmed (`TURNSTILE_FAILED`)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: ORIGIN_NOT_ALLOWED
                  message: 'This publishable key cannot be used from this origin.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          description: "The key has spent its owner's daily credit cap (`KEY_SPEND_CAP_REACHED`), or the IP exceeded 20 requests per minute (`RATE_LIMITED`)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: KEY_SPEND_CAP_REACHED
                  message: 'This publishable key has reached its daily credit cap. The counter resets each day.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/ip/public \
              -H "Origin: https://www.example.com" \
              -H "Content-Type: application/json" \
              -d '{
              "key": "pk_live_…"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/public', {
              method: 'POST',
              headers: {
                'Origin': 'https://www.example.com',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "key": "pk_live_…"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/ip/public',
                headers={'Origin': 'https://www.example.com'},
                json={
                    'key': 'pk_live_…'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/public');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Origin: https://www.example.com', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'key' => 'pk_live_…'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
  /api/v1/ip/bulk:
    get:
      operationId: list-ip-bulk-jobs
      tags:
        - IP
      summary: "List the account's bulk IP lookup runs"
      description: |
        Every bulk run this account has started, newest first, 100 per page. A job id is handed out once, at creation, and everything about a run hangs off it — its counts, its results, its cancel and resume calls — so this is how a caller that lost one finds the run again, and how a dashboard lists what is still in flight.

        `status`, `since` and `until` are optional and combine; a bare date in `until` means the whole of that day, and an unknown status or an unparseable date is a `422` rather than an empty page. The rows are the same job objects `GET /api/v1/ip/bulk/{jobId}` answers. The webhook secret is never among them: it is returned once, at creation.

        Runs are kept as long as the lookup history, and `meta.retention_days` says how long that is — a run older than that is deleted with its files and cannot be listed.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      parameters:
        -
          name: status
          in: query
          required: false
          schema:
            type: string
            enum:
              - queued
              - processing
              - completed
              - failed
              - cancelled
          example: completed
          description: 'Only runs in this status.'
        -
          name: since
          in: query
          required: false
          schema:
            type: string
          example: '2026-09-01'
          description: 'Only runs created at or after this date or timestamp.'
        -
          name: until
          in: query
          required: false
          schema:
            type: string
          example: '2026-09-08'
          description: 'Only runs created at or before this. A bare date means the whole of that day.'
        -
          name: page
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            default: 1
          example: 1
          description: 'The page to return.'
      responses:
        200:
          description: 'One page of runs, newest first.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      jobs:
                        type: array
                        items:
                          $ref: '#/components/schemas/IpBulkJob'
                  meta:
                    type: object
                    properties:
                      page:
                        type: integer
                      per_page:
                        type: integer
                      total:
                        type: integer
                      retention_days:
                        type: integer
                        description: 'How long a run and its files are kept before they are deleted.'
              example:
                success: true
                data:
                  jobs:
                    -
                      id: 31
                      status: processing
                      total: 40000
                      duplicate_count: 120
                      processed: 12000
                      low: 11400
                      medium: 500
                      high: 100
                      credits_used: 11880
                      stopped_reason: null
                      cancel_requested: false
                      webhook_status: null
                      webhook_detail: null
                      created_at: '2026-09-04T14:00:00+00:00'
                      finished_at: null
                meta:
                  page: 1
                  per_page: 100
                  total: 1
                  retention_days: 30
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/ip/bulk \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/bulk', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/ip/bulk',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/bulk');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.list_ip_bulk_jobs()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.listIpBulkJobs();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->listIpBulkJobs();
    post:
      operationId: create-ip-bulk-job
      tags:
        - IP
      summary: 'Queue a bulk IP lookup run'
      description: |
        Queues up to 100,000 addresses and processes them in the background. Creation answers `202` with the job's id and status. Poll `GET /api/v1/ip/bulk/{jobId}` for progress, then download the result CSV from `GET /api/v1/ip/bulk/{jobId}/results`: one row per input address with every response field as a column. Jobs and their files are kept for 30 days.

        Rows repeating an address already in the list are looked up once and counted in `duplicate_count`. A run that exhausts the balance stops with `stopped_reason: "insufficient_credits"` and keeps everything answered so far. Because nothing about an IP lookup is stored, the input list and the result file are the only copies and both are deleted with the job.

        With a `webhook_url`, the finish (`completed`, `failed`, or `cancelled`) is POSTed there as `{ "event": "bulk_ip_job.finished", "job": { … } }`, signed with `webhook_secret` exactly like bulk email runs: `X-Spaw-Signature-V2` carries `t={unix seconds},v2={lowercase hex}`, the HMAC-SHA256 of `v2:{t}:{raw body}`, and a receiver should refuse a `t` more than five minutes from its own clock so a captured delivery cannot be replayed at it later. The original `X-Spaw-Signature`, over the body alone, is sent beside it until 1 March 2027. Send an `Idempotency-Key` header to make creation safe to retry: a repeat of the same request answers the job the first attempt created, as `200` with `Idempotent-Replayed: true`; the same key with a different request answers `409 IDEMPOTENCY_KEY_REUSED`.

        ### Reading a list before you start it

        Send `preview: true` with the same body — or `csv` with a raw CSV instead of the item list — and the call answers `200` with `data.draft` instead of queueing anything: what the list was read as, which column filled which field and whether a heading named it or we guessed, the first rows as parsed, the repeats, the balance, and `credits_upper_bound`. Nothing is charged.

        `credits_upper_bound` is a CEILING, not a quote: one credit per five unique addresses, because IP lookups bill in blocks of five. Rows come back free for reasons no preview can know in advance — a repeat inside the seven-day window, a reserved, private or unallocated address, IP lookups already prepaid on the account — so what a run adds to your spend usually comes to less than the ceiling. Other IP traffic on the account can use prepaid lookups from a block the run opened, so the job's own `credits_used` can come out slightly above it. It is priced against the mapping in play, so correcting a column at start re-prices the run rather than holding you to the figure the guess produced — ask for the draft again after a correction if you want the new number. What it actually cost is `credits_used` on the finished job.

        Start it with `{"draft_id": <id>}`, adding `mapping` to correct a column we read wrongly. Starting consumes the draft; one left unstarted is deleted, with the rows it holds, after `retention_hours`.
      x-spaw-auth: bearer
      x-spaw-billing: "`preview: true` is free and queues nothing. A started run bills each row like a single lookup as it is processed; repeats inside the run and 7-day repeats are free, so what a run adds to your spend usually comes to no more than the `credits_upper_bound` the preview answered (other IP traffic can use lookups from a block the run opened, which can put the job's own `credits_used` slightly above it)."
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - IDEMPOTENCY_KEY_REUSED
        - RATE_LIMITED
      parameters:
        -
          name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            minLength: 1
            maxLength: 128
          description: 'A value unique to this request, so a retry after a timeout answers the same job instead of queueing a second one.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                ips:
                  type: array
                  minItems: 1
                  maxItems: 100000
                  items:
                    type: string
                  description: '1 to 100,000 IPv4 or IPv6 addresses. Required unless the body carries `csv` with `preview: true`, or `draft_id`.'
                preview:
                  type: boolean
                  default: false
                  description: 'Read the list and answer `data.draft` — what we made of it, the repeats, the ceiling on the cost and the balance — with nothing queued and nothing billed. Start it afterwards with `draft_id`.'
                csv:
                  type:
                    - string
                    - 'null'
                  maxLength: 5000000
                  description: "A raw CSV to read instead of the item list, with the column mapping answered back. Preview only, on purpose: a CSV's columns have to be worked out, and working them out and billing for the answer in one request is what the preview exists to stop."
                draft_id:
                  type:
                    - integer
                    - 'null'
                  description: 'Start the run a preview answered with. The rows and the options come from the draft, so nothing else in the body applies except `mapping`. A draft can be started once; an `Idempotency-Key` still makes the call safe to retry.'
                mapping:
                  type:
                    - object
                    - 'null'
                  additionalProperties:
                    type: string
                  description: 'Corrects the draft''s column mapping, as column index to field — {"1": "address_line1", "2": "postal_code"}. It REPLACES the detected mapping rather than merging into it, so a column can be taken off a field as well as put on one. Goes with `draft_id`, and only for a draft read from a `csv`.'
                webhook_url:
                  type:
                    - string
                    - 'null'
                  format: uri
                  description: 'An https URL to notify when the run finishes.'
                privacy:
                  type:
                    - boolean
                    - 'null'
                  description: 'Keep nothing about these lookups; every address is billed as a fresh lookup.'
            example:
              ips:
                - 8.8.8.8
                - 103.124.165.2
              webhook_url: 'https://example.com/hooks/spaw'
      responses:
        202:
          description: 'The job was queued.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        allOf:
                          -
                            $ref: '#/components/schemas/IpBulkJob'
                          -
                            type: object
                            properties:
                              webhook_secret:
                                type:
                                  - string
                                  - 'null'
                                description: 'Returned once. Verify webhook signatures with it.'
              example:
                success: true
                data:
                  job:
                    id: 512
                    status: queued
                    total: 2
                    duplicate_count: 0
                    processed: 0
                    low: 0
                    medium: 0
                    high: 0
                    credits_used: 0
                    stopped_reason: null
                    cancel_requested: false
                    webhook_status: null
                    created_at: '2026-09-04T10:12:44+00:00'
                    finished_at: null
                    webhook_secret: '8fJ2…40 characters…Qk1'
        200:
          description: 'Either a preview — `preview: true` answers `data.draft` and queues nothing — or a retry with an `Idempotency-Key` already used for this exact request, which answers `data.job` with `Idempotent-Replayed: true`.'
          headers:
            Idempotent-Replayed:
              schema:
                type: string
                enum:
                  - 'true'
              description: 'Present only on a replayed creation.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      draft:
                        $ref: '#/components/schemas/BulkDraft'
                      job:
                        $ref: '#/components/schemas/IpBulkJob'
              example:
                success: true
                data:
                  job:
                    id: 512
                    status: processing
                    total: 2
                    duplicate_count: 0
                    processed: 1
                    low: 0
                    medium: 1
                    high: 0
                    credits_used: 1
                    stopped_reason: null
                    cancel_requested: false
                    webhook_status: null
                    created_at: '2026-09-04T10:12:44+00:00'
                    finished_at: null
        401:
          $ref: '#/components/responses/Unauthenticated'
        409:
          description: 'The `Idempotency-Key` was already used for a different list or webhook URL.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: IDEMPOTENCY_KEY_REUSED
                  message: 'This Idempotency-Key was already used for a different request.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/ip/bulk \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "ips": [
                "8.8.8.8",
                "103.124.165.2"
              ],
              "webhook_url": "https://example.com/hooks/spaw"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/bulk', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "ips": [
                  "8.8.8.8",
                  "103.124.165.2"
                ],
                "webhook_url": "https://example.com/hooks/spaw"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/ip/bulk',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'ips': [
                        '8.8.8.8',
                        '103.124.165.2'
                    ],
                    'webhook_url': 'https://example.com/hooks/spaw'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/bulk');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'ips' => [
                        '8.8.8.8',
                        '103.124.165.2'
                    ],
                    'webhook_url' => 'https://example.com/hooks/spaw'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.create_ip_bulk_job([
                '8.8.8.8',
                '103.124.165.2'
            ], webhook_url='https://example.com/hooks/spaw')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.createIpBulkJob([
                '8.8.8.8',
                '103.124.165.2'
            ], {
                webhookUrl: 'https://example.com/hooks/spaw'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->createIpBulkJob([
                '8.8.8.8',
                '103.124.165.2'
            ], webhookUrl: 'https://example.com/hooks/spaw');
  '/api/v1/ip/bulk/{jobId}':
    get:
      operationId: get-ip-bulk-job
      tags:
        - IP
      summary: 'Poll a bulk IP job'
      description: |
        The job's current status and running counts. `status` moves from `queued` to `processing` and ends in `completed`, `failed`, or `cancelled`; `processed` and the `low`, `medium` and `high` counts advance as chunks finish. Once the run has settled, `summary` adds up what the rows say — the top ten countries and networks, how many rows carried each signal, how many hit one of your lists, and the risk split — built from the same cells the result file holds; it is null until then. A job that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'The job.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        $ref: '#/components/schemas/IpBulkJob'
              example:
                success: true
                data:
                  job:
                    id: 512
                    status: completed
                    total: 2
                    duplicate_count: 0
                    processed: 2
                    low: 0
                    medium: 1
                    high: 1
                    summary:
                      rows: 2
                      reserved: 0
                      countries:
                        -
                          country: US
                          rows: 1
                        -
                          country: AL
                          rows: 1
                      networks:
                        -
                          asn: 15169
                          org: 'Google LLC'
                          rows: 1
                        -
                          asn: 42237
                          org: 'w1n ltd'
                          rows: 1
                      signals:
                        datacenter: 2
                        corporate_proxy: 0
                        crawler: 0
                        tor: 0
                        vpn: 1
                        relay: 0
                        anonymous: 1
                        blocklisted: 0
                        attack_source: 0
                        mobile: 0
                        satellite: 0
                      listed:
                        allow: 0
                        deny: 0
                      risk:
                        low: 0
                        medium: 1
                        high: 1
                    credits_used: 2
                    stopped_reason: null
                    cancel_requested: false
                    webhook_status: delivered
                    created_at: '2026-09-04T10:12:44+00:00'
                    finished_at: '2026-09-04T10:12:46+00:00'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/ip/bulk/{jobId} \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/bulk/{jobId}', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/ip/bulk/{jobId}',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/bulk/{jobId}');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.get_ip_bulk_job(job_id)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.getIpBulkJob(jobId);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->getIpBulkJob($jobId);
  '/api/v1/ip/bulk/{jobId}/cancel':
    post:
      operationId: cancel-ip-bulk-job
      tags:
        - IP
      summary: 'Cancel a bulk IP job'
      description: |
        Asks a queued or running job to stop at its next checkpoint. Everything answered so far stays billed and stays in the result file. A job that already finished answers `409 JOB_ALREADY_FINISHED`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - JOB_ALREADY_FINISHED
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
      responses:
        202:
          description: 'Cancellation was requested.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        $ref: '#/components/schemas/IpBulkJob'
              example:
                success: true
                data:
                  job:
                    id: 512
                    status: processing
                    total: 20000
                    duplicate_count: 12
                    processed: 4000
                    low: 3100
                    medium: 700
                    high: 200
                    credits_used: 3900
                    stopped_reason: null
                    cancel_requested: true
                    webhook_status: null
                    created_at: '2026-09-04T10:12:44+00:00'
                    finished_at: null
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        409:
          description: 'The job already finished.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: JOB_ALREADY_FINISHED
                  message: 'This job has already finished.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/ip/bulk/{jobId}/cancel \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/bulk/{jobId}/cancel', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/ip/bulk/{jobId}/cancel',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/bulk/{jobId}/cancel');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.cancel_ip_bulk_job(job_id)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.cancelIpBulkJob(jobId);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->cancelIpBulkJob($jobId);
  '/api/v1/ip/bulk/{jobId}/resume':
    post:
      operationId: resume-ip-bulk-job
      tags:
        - IP
      summary: 'Resume a bulk job that ran out of credits'
      description: |
        Runs a job that stopped when the credit balance ran out again, from the top of the list it was given, instead of asking you to upload it a second time. Rows looked up in the last seven days come back as free repeats, so only the rows the stopped run never reached are charged.

        The job keeps its id, its webhook and its cumulative `credits_used`, which is the real spend on the account. `processed` and the verdict counts restart at zero: the part files they were counted from are deleted when a run settles, so the resumed run rebuilds them, and the result file the stopped run left is replaced as the rows come back.

        Only a run whose `stopped_reason` is `insufficient_credits` and whose `status` has settled can be resumed; anything else answers `409 JOB_NOT_RESUMABLE`, and so does a second resume of a run that is already going again — claiming the run is one conditional write, so two calls can never queue it twice. A run whose list has passed out of the retention window answers `409 JOB_INPUT_EXPIRED`. An empty balance answers `402 INSUFFICIENT_CREDITS` rather than queueing a run that would stop again on the first row it never reached.
      x-spaw-auth: bearer
      x-spaw-billing: 'Free to call. The resumed run bills the rows it reaches exactly as the first one did, and a row answered in the last seven days is a free repeat.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - JOB_NOT_RESUMABLE
        - JOB_INPUT_EXPIRED
        - INSUFFICIENT_CREDITS
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
      responses:
        202:
          description: 'The list is queued again.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        $ref: '#/components/schemas/IpBulkJob'
              example:
                success: true
                data:
                  job:
                    id: 512
                    status: queued
                    total: 20000
                    duplicate_count: 12
                    processed: 0
                    low: 0
                    medium: 0
                    high: 0
                    credits_used: 3900
                    stopped_reason: null
                    cancel_requested: false
                    webhook_status: null
                    created_at: '2026-09-04T10:12:44+00:00'
                    finished_at: null
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        404:
          $ref: '#/components/responses/NotFound'
        409:
          description: 'The run is not in a state a resume can recover (`JOB_NOT_RESUMABLE`), or its list is no longer stored (`JOB_INPUT_EXPIRED`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: JOB_NOT_RESUMABLE
                  message: 'Only a run that stopped because the credit balance ran out can be resumed, and only once it has finished settling.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/ip/bulk/{jobId}/resume \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/bulk/{jobId}/resume', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/ip/bulk/{jobId}/resume',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/bulk/{jobId}/resume');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.resume_ip_bulk_job(job_id)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.resumeIpBulkJob(jobId);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->resumeIpBulkJob($jobId);
  '/api/v1/ip/bulk/{jobId}/webhook/redeliver':
    post:
      operationId: redeliver-ip-bulk-webhook
      tags:
        - IP
      summary: "Send a finished run's completion webhook again"
      description: |
        POSTs a finished run's completion webhook to its `webhook_url` a second time. This is for the case the webhook exists to cover: the run finished, the delivery went out, your endpoint was down for the few minutes it was tried, and the message is gone. `webhook_status` and `webhook_detail` tell you that happened; this is how you ask for it again, instead of falling back to polling — which is the thing the webhook was set up to avoid.

        **The body is the same body.** It is rebuilt from the run's own row rather than replayed from a stored blob, and every field it carries — `status`, `processed`, the row counts, `credits_used`, `stopped_reason`, `finished_at` — is frozen once a run has settled. The bytes are the ones the first delivery carried, so reconciling a redelivery against the original compares equals. (A run that is resumed later settles again and posts a new completion webhook of its own; a redelivery always repeats the most recent one.)

        **The signature is new, and that is correct.** Each delivery is signed as it is sent, so `X-Spaw-Signature-V2` carries a later `t` and a different `v2` digest from the first attempt. That is required rather than incidental: a receiver refuses anything whose `t` is more than five minutes from its own clock, so a signature copied from the original would be refused on arrival. Verify a redelivery exactly as you verify any other delivery. The untimestamped `X-Spaw-Signature` is the HMAC of the body alone, so it *is* identical to the first attempt's, until it stops being sent on 1 March 2027.

        **Nothing deduplicates this for you.** Each call is one real POST, so a receiver that gets both the original and the redelivery sees the event twice. Both bodies carry `job.id`: treat a `bulk_ip_job.finished` for a job id you have already handled as a repeat and drop it.

        **The delivery is queued, and the answer is `202`.** One attempt is a 10-second timeout retried twice, about half a minute against a receiver that is down — which is the receiver you are asking about. So this call does not wait for it: it answers `202` at once with the run, and `webhook_status` on it reads `pending`, a delivery asked for and not yet answered. Poll the run (`GET /api/v1/ip/bulk/{jobId}`) until `webhook_status` is `delivered` or `failed`; `webhook_detail` then carries the HTTP status your endpoint gave, or the transport failure when it gave none. A `pending` run has one delivery in flight and asking again queues a second — wait for the first before you decide it failed.

        A run created without a `webhook_url` answers `409 WEBHOOK_NOT_CONFIGURED`, which is checked FIRST because waiting will never fix it; one still queued or running answers `409 JOB_NOT_FINISHED`, because there is no completion to announce yet, and so does a run that stops being finished between the check and the queueing (a resume in another window). A run whose last delivery *succeeded* is not refused — a receiver can lose a message it has already acknowledged, and only you know whether it did.

        A URL that resolves onto a private address is refused at delivery here exactly as on the first attempt, every time, because DNS can be repointed after a URL is saved.
      x-spaw-auth: bearer
      x-spaw-billing: "Free. A redelivery spends no credits and bills nothing. It is capped at 10 a minute counted per signed-in user — neither per key nor per account, so two keys held by one person share the one budget while two teammates have one each, and the dashboard button and the other products' redeliveries all spend the caller's own."
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - WEBHOOK_NOT_CONFIGURED
        - JOB_NOT_FINISHED
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
          example: 512
          description: 'The id returned at creation.'
      responses:
        202:
          description: 'The delivery is queued; `webhook_status` reads `pending` until a worker has an answer.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        $ref: '#/components/schemas/IpBulkJob'
              example:
                success: true
                data:
                  job:
                    id: 512
                    status: completed
                    total: 20000
                    duplicate_count: 12
                    processed: 20000
                    low: 18100
                    medium: 1600
                    high: 300
                    credits_used: 19600
                    stopped_reason: null
                    cancel_requested: false
                    webhook_status: pending
                    webhook_detail: null
                    created_at: '2026-09-04T10:12:44+00:00'
                    finished_at: '2026-09-04T10:31:02+00:00'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        409:
          description: 'The run has no webhook URL (`WEBHOOK_NOT_CONFIGURED`), or it has not finished yet (`JOB_NOT_FINISHED`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: WEBHOOK_NOT_CONFIGURED
                  message: 'This run was created without a webhook URL, so there is no completion webhook to send.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          description: 'More than ten redeliveries in a minute from this user, across every product and the dashboard button alike. The shared limit named on other endpoints is not the one that fires here.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: RATE_LIMITED
                  message: 'Too many requests. Retry after the limit resets.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/ip/bulk/512/webhook/redeliver \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/bulk/512/webhook/redeliver', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/ip/bulk/512/webhook/redeliver',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/bulk/512/webhook/redeliver');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.redeliver_ip_bulk_webhook(512)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.redeliverIpBulkWebhook(512);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->redeliverIpBulkWebhook(512);
  '/api/v1/ip/bulk/{jobId}/results':
    get:
      operationId: download-ip-bulk-results
      tags:
        - IP
      summary: "Download a bulk IP job's results"
      description: |
        The result file as CSV: one row per input address, in input order, with the fields of the single-address response as columns plus the credits it cost. Two structured fields are flattened: `sanctions` as its codes joined with `;`, `account_list` as `kind:name`. Left out, because they belong to one live request rather than a row: `timezone_matches` (needs a browser), `seen_by_you` (would count the run's own rows), and the opt-in network fields (`abuse_contact`, `network_details`, the hostname fields). Fields containing spaces are quoted. Add `?variant=low|medium|high` to download only the rows of one risk level; the default `full` returns everything. Available once the job is `completed`, or `cancelled` or `failed` with at least one processed row — those rows were billed, so they stay downloadable. A job still running answers `409 RESULTS_NOT_READY`, a cancelled job with nothing processed `409 JOB_CANCELLED`, and a failed job with nothing processed `409 JOB_FAILED`.

        `format=json` answers the same rows, filtered the same way, as objects keyed by the result file's own header instead of a CSV attachment: `{ "success": true, "data": { "job": …, "results": [ … ] }, "meta": { "variant", "request_id" } }`. It is streamed row by row exactly as the CSV is, so a 100,000-row run costs no more to answer either way. A CSV cell carries no type, so only the two the writer encodes come back typed: an empty cell is `null` and `true`/`false` are booleans. Everything else is the string as written — a number is never silently made numeric, because postcodes, phone numbers and house numbers lose their leading zeros that way — and the columns of an uploaded file come back exactly as they were uploaded.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - VALIDATION_FAILED
        - RESULTS_NOT_READY
        - JOB_CANCELLED
        - JOB_FAILED
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
        -
          name: variant
          in: query
          required: false
          schema:
            type: string
            enum:
              - full
              - low
              - medium
              - high
          description: 'Restrict the file to one risk level. Defaults to full.'
        -
          name: format
          in: query
          required: false
          schema:
            type: string
            enum:
              - csv
              - json
            default: csv
          example: json
          description: '`csv` streams the file as an attachment; `json` streams the same rows as objects.'
      responses:
        200:
          description: 'The result rows, as a CSV attachment or as streamed JSON.'
          content:
            text/csv:
              schema:
                type: string
              example: |
                ip,version,reason,network,continent,country,country_name,is_eu,is_eea,privacy_regime,sanctions,fatf_list,region,city,latitude,longitude,location_source,location_confidence,timezone,timezone_source,currency,calling_code,asn,org,asn_type,connection_type,is_mobile,is_satellite,registry,registry_country,allocated_at,is_datacenter,hosting_provider,is_corporate_proxy,corporate_proxy,is_crawler,crawler,crawler_kind,is_blocklisted,blocklist,is_attack_source,attack_source,is_tor,tor_last_seen,is_vpn,is_relay,privacy_service,is_anonymous,account_list,risk_score,risk_level,credits_used
                8.8.8.8,4,,8.8.8.0/24,NA,US,United States,false,false,ccpa,,,California,Mountain View,37.4056,-122.0775,database,low,America/Los_Angeles,region,USD,1,15169,Google LLC,hosting,hosting,false,false,arin,US,2023-12-28,true,,false,,false,,,false,,false,,false,,false,false,,false,,40,medium,1
            application/json:
              schema:
                $ref: '#/components/schemas/BulkResultRows'
              example:
                success: true
                data:
                  job:
                    id: 31
                    status: completed
                  results:
                    -
                      ip: 8.8.8.8
                      version: '4'
                      reason: null
                      network: 8.8.8.0/24
                      country: US
                      asn: '15169'
                      org: 'Google LLC'
                      is_datacenter: true
                      risk_score: '40'
                      risk_level: medium
                      credits_used: '1'
                meta:
                  variant: full
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        409:
          description: 'The job is still running (`RESULTS_NOT_READY`), or was cancelled or failed before any row was processed (`JOB_CANCELLED`, `JOB_FAILED`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: RESULTS_NOT_READY
                  message: 'The results are not ready yet.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/ip/bulk/{jobId}/results \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/bulk/{jobId}/results', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/ip/bulk/{jobId}/results',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/bulk/{jobId}/results');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.download_ip_bulk_results(job_id)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.downloadIpBulkResults(jobId);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->downloadIpBulkResults($jobId);
  /api/v1/ip/feedback:
    post:
      operationId: report-ip-feedback
      tags:
        - IP
      summary: 'Report IP outcomes'
      description: |
        Tells Spaw what an address turned out to be after the fact: `abuse` (spam, scraping, credential stuffing), `fraud` (a confirmed fraudulent order or account), `bot` (automated traffic that got through), or `chargeback`. Your own reports feed your own later lookups of that address: any report inside 30 days scores `reported_abuse` (+60) with the dataset `abuse-feedback`. Once three or more accounts report the same address inside 30 days, every account's lookups of it score `reported_abuse_widely` (+40); only the number of reporting accounts is ever read, never who reported or what. Neither signal turns a null score into a number: an address no feed evaluated stays unscored.

        Addresses are canonicalised like a lookup and stored only as a salted SHA-256 hash with the outcome and a timestamp, never in clear; a hash can confirm a later lookup of the same address but cannot be read back into one. Items whose address is malformed or in a reserved range are skipped and counted, not rejected. Feedback is free, never billed and never logged as a lookup. Reports are kept for 90 days.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - items
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 1000
                  items:
                    $ref: '#/components/schemas/IpFeedbackItem'
                  description: '1 to 1,000 outcomes per request.'
            example:
              items:
                -
                  ip: 203.0.113.9
                  outcome: fraud
                -
                  ip: '2001:db8::1'
                  outcome: bot
                  occurred_at: '2026-09-05T09:00:00Z'
      responses:
        202:
          description: 'The outcomes were recorded.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      recorded:
                        type: integer
                      skipped:
                        type: integer
                        description: 'Items whose address was malformed or in a reserved range.'
              example:
                success: true
                data:
                  recorded: 2
                  skipped: 0
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/ip/feedback \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "items": [
                {
                  "ip": "203.0.113.9",
                  "outcome": "fraud"
                },
                {
                  "ip": "2001:db8::1",
                  "outcome": "bot",
                  "occurred_at": "2026-09-05T09:00:00Z"
                }
              ]
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/feedback', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "items": [
                  {
                    "ip": "203.0.113.9",
                    "outcome": "fraud"
                  },
                  {
                    "ip": "2001:db8::1",
                    "outcome": "bot",
                    "occurred_at": "2026-09-05T09:00:00Z"
                  }
                ]
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/ip/feedback',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'items': [
                        {
                            'ip': '203.0.113.9',
                            'outcome': 'fraud'
                        },
                        {
                            'ip': '2001:db8::1',
                            'outcome': 'bot',
                            'occurred_at': '2026-09-05T09:00:00Z'
                        }
                    ]
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/feedback');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'items' => [
                        [
                            'ip' => '203.0.113.9',
                            'outcome' => 'fraud'
                        ],
                        [
                            'ip' => '2001:db8::1',
                            'outcome' => 'bot',
                            'occurred_at' => '2026-09-05T09:00:00Z'
                        ]
                    ]
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.report_ip_feedback([
                {
                    'ip': '203.0.113.9',
                    'outcome': 'fraud'
                },
                {
                    'ip': '2001:db8::1',
                    'outcome': 'bot',
                    'occurred_at': '2026-09-05T09:00:00Z'
                }
            ])
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.reportIpFeedback([
                {
                    ip: '203.0.113.9',
                    outcome: 'fraud'
                },
                {
                    ip: '2001:db8::1',
                    outcome: 'bot',
                    occurred_at: '2026-09-05T09:00:00Z'
                }
            ]);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->reportIpFeedback([
                [
                    'ip' => '203.0.113.9',
                    'outcome' => 'fraud'
                ],
                [
                    'ip' => '2001:db8::1',
                    'outcome' => 'bot',
                    'occurred_at' => '2026-09-05T09:00:00Z'
                ]
            ]);
  /api/v1/ip/feedback/summary:
    get:
      operationId: ip-feedback-summary
      tags:
        - IP
      summary: 'Reported IP outcomes'
      description: |
        The last 90 days of outcomes you reported: totals per outcome, how many distinct addresses they cover, and the split between IPv4 and IPv6. No address appears, because none is stored.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - RATE_LIMITED
      responses:
        200:
          description: 'The summary.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/IpFeedbackSummary'
              example:
                success: true
                data:
                  window_days: 90
                  total: 412
                  addresses: 377
                  outcomes:
                    abuse: 208
                    fraud: 96
                    bot: 101
                    chargeback: 7
                  by_version:
                    4: 401
                    6: 11
        401:
          $ref: '#/components/responses/Unauthenticated'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/ip/feedback/summary \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/feedback/summary', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/ip/feedback/summary',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/feedback/summary');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.ip_feedback_summary()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.ipFeedbackSummary();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->ipFeedbackSummary();
  /api/v1/ip/monitors:
    get:
      operationId: list-ip-monitors
      tags:
        - IP
      summary: "List the account's IP monitors"
      description: |
        The saved address lists this account re-checks on a schedule, newest first, 100 per page. Each monitor carries the summary of its last run: how many addresses were clean, flagged (on a threat blocklist, a Tor exit, or scoring high) or reserved, how many changed status, how many newly became flagged, the credits the run spent, and `stopped_reason` when it did not finish cleanly: `insufficient_credits` or `key_spend_cap` where the run stopped part-way, `run_failed` where it died outright, and `run_failed_retrying` where it died and the schedule has been pulled in to check again — `retry_at` in the same block says when that becomes due, and the hourly sweep picks it up at or after that, so between 45 minutes and about an hour and three quarters later. A summary carrying a `stopped_reason` of either failure kind has NO counts in it at all: the run produced none, so read a missing count as unknown rather than as zero. The addresses and their statuses are on the single-monitor endpoint.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - RATE_LIMITED
      parameters:
        -
          name: page
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            default: 1
          example: 1
          description: 'The page to return.'
      responses:
        200:
          description: 'One page of monitors.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitors:
                        type: array
                        items:
                          $ref: '#/components/schemas/IpMonitor'
                  meta:
                    type: object
                    properties:
                      page:
                        type: integer
                      per_page:
                        type: integer
                      total:
                        type: integer
                      retention_days:
                        type: integer
                        description: 'How long a list nobody runs is kept before it is deleted.'
              example:
                success: true
                data:
                  monitors:
                    -
                      id: 4
                      name: 'Mail servers'
                      cadence: daily
                      ip_count: 2
                      next_run_at: '2026-09-06T08:00:00+00:00'
                      last_run_at: '2026-09-05T08:00:03+00:00'
                      last_summary:
                        total: 2
                        clean: 1
                        flagged: 1
                        reserved: 0
                        changed: 1
                        newly_flagged: 1
                        credits_used: 0
                        stopped_reason: null
                      created_at: '2026-08-27T08:00:00+00:00'
                meta:
                  page: 1
                  per_page: 100
                  total: 1
                  retention_days: 365
        401:
          $ref: '#/components/responses/Unauthenticated'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/ip/monitors \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/monitors', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/ip/monitors',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/monitors');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.list_ip_monitors()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.listIpMonitors();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->listIpMonitors();
    post:
      operationId: create-ip-monitor
      tags:
        - IP
      summary: 'Monitor your own addresses on a schedule'
      description: |
        Saves up to 500 of your own addresses, your egress addresses, mail servers and office connections, and re-checks them every day, week or month. Addresses are canonicalised and deduplicated. The first run starts right away and only sets the baseline; from the next run on, every address that newly lands on a threat blocklist (Spamhaus DROP, ASN-DROP or the botnet C2 list), becomes a Tor exit, or scores high is reported to the account's email with the reason, so you learn that a mail server sits on a blocklist before your recipients' inboxes do.

        Answers `201` with the monitor. The baseline run is queued, not finished: poll the monitor for `last_run_at` and `last_summary`.

        With a `webhook_url`, a finished run is POSTed there as `{ "event": "ip_monitor.run", "monitor": { "id", "name", "cadence", "last_run_at", "next_run_at" }, "summary": { … the same block as `last_summary` … }, "changes": [ … ] }`, where `changes` names the addresses the alert email names. The `X-Spaw-Signature-V2` header carries `t={unix seconds},v2={lowercase hex}`, where the hex is the HMAC-SHA256 of `v2:{t}:{raw body}` keyed with `webhook_secret`, which this response returns once; a receiver should refuse anything whose `t` is more than five minutes from its own clock, since a signature over the body alone would let a captured delivery be replayed at it forever. The original `X-Spaw-Signature`, that HMAC of the body alone, is sent beside it until 1 March 2027. Connection errors and 5xx answers are retried twice with a short backoff, three attempts in all, and the outcome is readable afterwards as `webhook_status` and `webhook_detail`. A URL that resolves onto a private network is never posted to.
      x-spaw-auth: bearer
      x-spaw-billing: 'Creation is free. Each run bills every address like a single lookup: 1 credit per 5 fresh answered lookups; reserved and unallocated addresses and 7-day repeats are free, so a daily list costs at most one credit per five addresses a week.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
                - ips
                - cadence
              properties:
                name:
                  type: string
                  maxLength: 100
                  description: 'A label for the alerts.'
                ips:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type: string
                  description: '1 to 500 IPv4 or IPv6 addresses, or prefixes of at most 256 addresses (/24 to /32, /120 to /128), which are expanded into their addresses before the cap; duplicates and alternative spellings of one address count once. A wider prefix answers 422 — split it, because a /16 is 65,536 lookups a run.'
                cadence:
                  type: string
                  enum:
                    - daily
                    - weekly
                    - monthly
                  description: 'How often the list is re-checked.'
                webhook_url:
                  type:
                    - string
                    - 'null'
                  maxLength: 2048
                  format: uri
                  description: 'An https URL every reported run is POSTed to. The signing secret is returned once, in this response, and never again.'
                webhook_events:
                  type: string
                  enum:
                    - changes
                    - every_run
                  default: changes
                  description: 'When to post. `changes` posts only when the run found something new — decay, a status change, a newly flagged entry — or stopped early; `every_run` posts after every run, which also says the monitor is still running.'
            example:
              name: 'Mail servers'
              ips:
                - 203.0.113.25
                - '2001:db8::25'
              cadence: daily
      responses:
        201:
          description: 'The monitor was saved and its baseline run queued.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        allOf:
                          -
                            $ref: '#/components/schemas/IpMonitor'
                          -
                            type: object
                            properties:
                              webhook_secret:
                                type:
                                  - string
                                  - 'null'
                                description: 'The webhook signing secret, shown once here and never readable again. Null when the monitor has no webhook.'
              example:
                success: true
                data:
                  monitor:
                    id: 4
                    name: 'Mail servers'
                    cadence: daily
                    ip_count: 2
                    next_run_at: '2026-09-06T10:12:44+00:00'
                    last_run_at: null
                    last_summary: null
                    created_at: '2026-09-05T10:12:44+00:00'
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/ip/monitors \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "name": "Mail servers",
              "ips": [
                "203.0.113.25",
                "2001:db8::25"
              ],
              "cadence": "daily"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/monitors', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "name": "Mail servers",
                "ips": [
                  "203.0.113.25",
                  "2001:db8::25"
                ],
                "cadence": "daily"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/ip/monitors',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'name': 'Mail servers',
                    'ips': [
                        '203.0.113.25',
                        '2001:db8::25'
                    ],
                    'cadence': 'daily'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/monitors');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'name' => 'Mail servers',
                    'ips' => [
                        '203.0.113.25',
                        '2001:db8::25'
                    ],
                    'cadence' => 'daily'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.create_ip_monitor(name='Mail servers', ips=[
                '203.0.113.25',
                '2001:db8::25'
            ], cadence='daily')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.createIpMonitor({
                name: 'Mail servers',
                ips: [
                    '203.0.113.25',
                    '2001:db8::25'
                ],
                cadence: 'daily'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->createIpMonitor(name: 'Mail servers', ips: [
                '203.0.113.25',
                '2001:db8::25'
            ], cadence: 'daily');
  '/api/v1/ip/monitors/{monitorId}':
    get:
      operationId: get-ip-monitor
      tags:
        - IP
      summary: 'Read an IP monitor and its last results'
      description: |
        The monitor plus `results`: for every address from the last run, its status (`clean`, `flagged` or `reserved`), the flags that fired (`blocklisted`, `attack_source`, `tor`, `high_risk`), the list that names it, its score and level, and whether the status changed since the previous run; keyed by the address in canonical form, or null until the first run has finished. A monitor that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
          example: 4
          description: "The monitor's id from the list endpoint."
      responses:
        200:
          description: 'The monitor.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        allOf:
                          -
                            $ref: '#/components/schemas/IpMonitor'
                          -
                            type: object
                            properties:
                              results:
                                type:
                                  - object
                                  - 'null'
                                additionalProperties:
                                  $ref: '#/components/schemas/IpMonitorResult'
                                description: "The last run's answer per address, keyed by the address; null until the first run finishes."
              example:
                success: true
                data:
                  monitor:
                    id: 4
                    name: 'Mail servers'
                    cadence: daily
                    ip_count: 2
                    next_run_at: '2026-09-06T08:00:00+00:00'
                    last_run_at: '2026-09-05T08:00:03+00:00'
                    last_summary:
                      total: 2
                      clean: 1
                      flagged: 1
                      reserved: 0
                      changed: 1
                      newly_flagged: 1
                      credits_used: 0
                      stopped_reason: null
                    created_at: '2026-08-27T08:00:00+00:00'
                    results:
                      203.0.113.25:
                        status: flagged
                        flags:
                          - blocklisted
                          - high_risk
                        blocklist: 'Spamhaus DROP'
                        risk_score: 80
                        risk_level: high
                        changed: true
                      '2001:db8::25':
                        status: clean
                        flags: []
                        blocklist: null
                        risk_score: 0
                        risk_level: low
                        changed: false
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/ip/monitors/4 \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/monitors/4', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/ip/monitors/4',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/monitors/4');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.get_ip_monitor(4)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.getIpMonitor(4);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->getIpMonitor(4);
    patch:
      operationId: update-ip-monitor
      tags:
        - IP
      summary: 'Change a monitor'
      description: |
        Changes a saved monitor in place: rename it, move it to another cadence, replace the addresses, or point it at a webhook. Send only the fields you are changing; a body that names none of them answers `422` rather than quietly changing nothing, because that is the shape a misspelled field name takes.

        Replacing `ips` replaces the list. What the last run recorded about the addresses that stayed is kept, so their next run still compares against the answer they already had; what it recorded about the ones that are gone is deleted with them; and an entry that is new starts with no baseline, so its first run only sets one. A rename never touches a stored answer — they are keyed by the entry, never by the list.

        Changing `cadence` neither skips a run nor starts a second one: a run already scheduled sooner than the new interval still happens, and a longer interval applies from the one after it. Nothing is queued by this call — the change applies to the next run, and `POST .../run` is there to have it now.

        Sending a `webhook_url` that differs from the one stored mints a new signing secret and returns it once, here; sending the URL it already has keeps the secret it is signing with, and `null` removes the webhook. A monitor that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: 'Free. The change applies to the next run.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - VALIDATION_FAILED
        - RATE_LIMITED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
          example: 41
          description: "The monitor's id from the list endpoint."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                name:
                  type: string
                  maxLength: 100
                ips:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type: string
                  description: 'Addresses, or prefixes of at most 256 addresses, expanded before the cap; a wider prefix answers 422.'
                cadence:
                  type: string
                  enum:
                    - daily
                    - weekly
                    - monthly
                webhook_url:
                  type:
                    - string
                    - 'null'
                  maxLength: 2048
                  format: uri
                webhook_events:
                  type: string
                  enum:
                    - changes
                    - every_run
            example:
              ips:
                - 203.0.113.7
                - 198.51.100.24
              cadence: daily
      responses:
        200:
          description: 'The monitor as it now stands.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        allOf:
                          -
                            $ref: '#/components/schemas/IpMonitor'
                          -
                            type: object
                            properties:
                              webhook_secret:
                                type: string
                                description: 'Present only when this call pointed the monitor at a URL it was not already posting to. Shown once.'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X PATCH https://spaw.co/api/v1/ip/monitors/41 \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "ips": [
                "203.0.113.7",
                "198.51.100.24"
              ],
              "cadence": "daily"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/monitors/41', {
              method: 'PATCH',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "ips": [
                  "203.0.113.7",
                  "198.51.100.24"
                ],
                "cadence": "daily"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.patch(
                'https://spaw.co/api/v1/ip/monitors/41',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'ips': [
                        '203.0.113.7',
                        '198.51.100.24'
                    ],
                    'cadence': 'daily'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/monitors/41');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'PATCH',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'ips' => [
                        '203.0.113.7',
                        '198.51.100.24'
                    ],
                    'cadence' => 'daily'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.update_ip_monitor(41, ips=[
                '203.0.113.7',
                '198.51.100.24'
            ], cadence='daily')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.updateIpMonitor(41, {
                ips: [
                    '203.0.113.7',
                    '198.51.100.24'
                ],
                cadence: 'daily'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->updateIpMonitor(41, [
                'ips' => [
                    '203.0.113.7',
                    '198.51.100.24'
                ],
                'cadence' => 'daily'
            ]);
    delete:
      operationId: delete-ip-monitor
      tags:
        - IP
      summary: 'Stop monitoring an IP list'
      description: |
        Removes the monitor and its stored results; nothing further is scheduled. A run that is already queued finishes on its own and bills as usual. A monitor that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
          example: 4
          description: "The monitor's id from the list endpoint."
      responses:
        200:
          description: 'The monitor was removed.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
              example:
                success: true
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X DELETE https://spaw.co/api/v1/ip/monitors/4 \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/monitors/4', {
              method: 'DELETE',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.delete(
                'https://spaw.co/api/v1/ip/monitors/4',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/monitors/4');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'DELETE',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.delete_ip_monitor(4)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.deleteIpMonitor(4);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->deleteIpMonitor(4);
  '/api/v1/ip/monitors/{monitorId}/run':
    post:
      operationId: run-ip-monitor
      tags:
        - IP
      summary: 'Re-check a monitored IP list now'
      description: |
        Queues a run right away instead of waiting for the schedule; when it finishes, the next scheduled run is set one cadence from then. Answers `202` with the monitor as it stands before the run; poll it for the new `last_run_at`, `last_summary` and `results`. A monitor that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: 'The run bills every address like a single lookup, 1 credit per 5 fresh answered addresses; reserved and unallocated addresses and 7-day repeats are free.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
          example: 4
          description: "The monitor's id from the list endpoint."
      responses:
        202:
          description: 'The run was queued.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        $ref: '#/components/schemas/IpMonitor'
              example:
                success: true
                data:
                  monitor:
                    id: 4
                    name: 'Mail servers'
                    cadence: daily
                    ip_count: 2
                    next_run_at: '2026-09-06T08:00:00+00:00'
                    last_run_at: '2026-09-05T08:00:03+00:00'
                    last_summary:
                      total: 2
                      clean: 1
                      flagged: 1
                      reserved: 0
                      changed: 1
                      newly_flagged: 1
                      credits_used: 0
                      stopped_reason: null
                    created_at: '2026-08-27T08:00:00+00:00'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/ip/monitors/4/run \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/monitors/4/run', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/ip/monitors/4/run',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/monitors/4/run');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.run_ip_monitor(4)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.runIpMonitor(4);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->runIpMonitor(4);
  /api/v1/ip/lists:
    get:
      operationId: list-ip-lists
      tags:
        - IP
      summary: "List the account's allow and deny lists"
      description: |
        Every allow and deny list on the account, oldest first, without the entries. A list belongs to the account rather than to whoever created it: every member's lookups apply it and any member may change or remove it. `meta` carries the two limits, how many lists an account may hold and how many entries one list may carry.

        The lists apply to every lookup the account is billed for — the API, the dashboard, batches, bulk runs, monitors and the browser widget. Deny lists are checked before allow lists, so a contradiction fails closed. A lookup that hits one carries `account_list` (`{ id, name, kind, entry }`): an allow-list hit answers `risk_score: 0` with a single `allowlisted` signal of weight 0, a deny-list hit adds `denylisted` 100, and `sources` gains `account-lists` with the list's last change as its version.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - RATE_LIMITED
      responses:
        200:
          description: "The account's lists."
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      lists:
                        type: array
                        items:
                          $ref: '#/components/schemas/IpList'
                  meta:
                    type: object
                    properties:
                      max_lists:
                        type: integer
                        description: 'How many lists the account may hold.'
                      max_entries:
                        type: integer
                        description: 'How many addresses and prefixes one list may carry.'
              example:
                success: true
                data:
                  lists:
                    -
                      id: 7
                      name: 'Office egress'
                      kind: allow
                      entry_count: 2
                      created_at: '2026-09-12T09:00:00+00:00'
                      updated_at: '2026-09-12T09:00:00+00:00'
                meta:
                  max_lists: 20
                  max_entries: 5000
        401:
          $ref: '#/components/responses/Unauthenticated'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/ip/lists \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/lists', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/ip/lists',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/lists');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.list_ip_lists()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.listIpLists();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->listIpLists();
    post:
      operationId: create-ip-list
      tags:
        - IP
      summary: 'Create an allow or deny list'
      description: |
        Saves a list of addresses and prefixes the account's lookups should treat as known-good (`allow`) or known-bad (`deny`): office and partner egress, a scraper's ranges, a fraud ring's space. Entries are IPv4 or IPv6 addresses or CIDR prefixes, canonicalised (an IPv4-mapped address collapses to dotted quad, `8.8.8.9/24` to `8.8.8.0/24`) and deduplicated before validation; a start-end range or a hostname is refused with `422`. The list applies from the account's next lookup.

        An account may hold `meta.max_lists` lists (20) of `meta.max_entries` entries (5,000) each; the twenty-first answers `409 IP_LIST_LIMIT_REACHED`. Answers `201` with the list and its entries.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - IP_LIST_LIMIT_REACHED
        - RATE_LIMITED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
                - kind
                - entries
              properties:
                name:
                  type: string
                  maxLength: 100
                  description: 'A label, answered back in `account_list.name` on every lookup the list matches.'
                kind:
                  type: string
                  enum:
                    - allow
                    - deny
                  description: '`allow` zeroes the score of a matching address; `deny` adds the denylisted weight of 100.'
                entries:
                  type: array
                  minItems: 1
                  maxItems: 5000
                  items:
                    type: string
                  description: '1 to 5,000 IPv4 or IPv6 addresses or CIDR prefixes; duplicates and alternative spellings count once.'
            example:
              name: 'Office egress'
              kind: allow
              entries:
                - 203.0.113.0/24
                - '2001:db8:10::/48'
      responses:
        201:
          description: 'The list was saved and applies from the next lookup.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      list:
                        $ref: '#/components/schemas/IpList'
              example:
                success: true
                data:
                  list:
                    id: 7
                    name: 'Office egress'
                    kind: allow
                    entry_count: 2
                    created_at: '2026-09-12T09:00:00+00:00'
                    updated_at: '2026-09-12T09:00:00+00:00'
                    entries:
                      - 203.0.113.0/24
                      - '2001:db8:10::/48'
        401:
          $ref: '#/components/responses/Unauthenticated'
        409:
          description: 'The account already holds as many lists as it may (`IP_LIST_LIMIT_REACHED`). Nothing was created.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: IP_LIST_LIMIT_REACHED
                  message: 'This account already holds 20 IP lists. Remove one, or add the entries to a list you have.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/ip/lists \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "name": "Office egress",
              "kind": "allow",
              "entries": [
                "203.0.113.0/24",
                "2001:db8:10::/48"
              ]
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/lists', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "name": "Office egress",
                "kind": "allow",
                "entries": [
                  "203.0.113.0/24",
                  "2001:db8:10::/48"
                ]
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/ip/lists',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'name': 'Office egress',
                    'kind': 'allow',
                    'entries': [
                        '203.0.113.0/24',
                        '2001:db8:10::/48'
                    ]
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/lists');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'name' => 'Office egress',
                    'kind' => 'allow',
                    'entries' => [
                        '203.0.113.0/24',
                        '2001:db8:10::/48'
                    ]
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.create_ip_list('Office egress', 'allow', [
                '203.0.113.0/24',
                '2001:db8:10::/48'
            ])
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.createIpList('Office egress', 'allow', [
                '203.0.113.0/24',
                '2001:db8:10::/48'
            ]);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->createIpList('Office egress', 'allow', [
                '203.0.113.0/24',
                '2001:db8:10::/48'
            ]);
  '/api/v1/ip/lists/{listId}':
    get:
      operationId: get-ip-list
      tags:
        - IP
      summary: 'Read a list and its entries'
      description: |
        One list with its entries in canonical form. A list that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: listId
          in: path
          required: true
          schema:
            type: integer
          example: 7
          description: "The list's id from the index."
      responses:
        200:
          description: 'The list.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      list:
                        $ref: '#/components/schemas/IpList'
              example:
                success: true
                data:
                  list:
                    id: 7
                    name: 'Office egress'
                    kind: allow
                    entry_count: 2
                    created_at: '2026-09-12T09:00:00+00:00'
                    updated_at: '2026-09-12T09:00:00+00:00'
                    entries:
                      - 203.0.113.0/24
                      - '2001:db8:10::/48'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/ip/lists/7 \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/lists/7', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/ip/lists/7',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/lists/7');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.get_ip_list(7)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.getIpList(7);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->getIpList(7);
    patch:
      operationId: update-ip-list
      tags:
        - IP
      summary: 'Change a list'
      description: |
        Renames a list, changes its kind, or replaces its entries with the ones sent. Send only the fields you are changing; a body that names none of `name`, `kind` and `entries` answers `422` rather than quietly changing nothing, because that is the shape a misspelled field name takes. Entries are canonicalised and deduplicated exactly as on creation, and the change applies from the account's next lookup. A list that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - VALIDATION_FAILED
        - RATE_LIMITED
      parameters:
        -
          name: listId
          in: path
          required: true
          schema:
            type: integer
          example: 7
          description: "The list's id from the index."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                name:
                  type: string
                  maxLength: 100
                kind:
                  type: string
                  enum:
                    - allow
                    - deny
                entries:
                  type: array
                  minItems: 1
                  maxItems: 5000
                  items:
                    type: string
                  description: "Replaces the list's entries."
            example:
              name: 'Office and warehouse egress'
              entries:
                - 203.0.113.0/24
                - '2001:db8:10::/48'
                - 198.51.100.7
      responses:
        200:
          description: 'The list as it now stands.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      list:
                        $ref: '#/components/schemas/IpList'
              example:
                success: true
                data:
                  list:
                    id: 7
                    name: 'Office and warehouse egress'
                    kind: allow
                    entry_count: 3
                    created_at: '2026-09-12T09:00:00+00:00'
                    updated_at: '2026-09-12T11:30:00+00:00'
                    entries:
                      - 203.0.113.0/24
                      - '2001:db8:10::/48'
                      - 198.51.100.7
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X PATCH https://spaw.co/api/v1/ip/lists/7 \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "name": "Office and warehouse egress",
              "entries": [
                "203.0.113.0/24",
                "2001:db8:10::/48",
                "198.51.100.7"
              ]
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/lists/7', {
              method: 'PATCH',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "name": "Office and warehouse egress",
                "entries": [
                  "203.0.113.0/24",
                  "2001:db8:10::/48",
                  "198.51.100.7"
                ]
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.patch(
                'https://spaw.co/api/v1/ip/lists/7',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'name': 'Office and warehouse egress',
                    'entries': [
                        '203.0.113.0/24',
                        '2001:db8:10::/48',
                        '198.51.100.7'
                    ]
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/lists/7');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'PATCH',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'name' => 'Office and warehouse egress',
                    'entries' => [
                        '203.0.113.0/24',
                        '2001:db8:10::/48',
                        '198.51.100.7'
                    ]
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.update_ip_list(7, name='Office and warehouse egress', entries=[
                '203.0.113.0/24',
                '2001:db8:10::/48',
                '198.51.100.7'
            ])
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.updateIpList(7, {
                name: 'Office and warehouse egress',
                entries: [
                    '203.0.113.0/24',
                    '2001:db8:10::/48',
                    '198.51.100.7'
                ]
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->updateIpList(7, [
                'name' => 'Office and warehouse egress',
                'entries' => [
                    '203.0.113.0/24',
                    '2001:db8:10::/48',
                    '198.51.100.7'
                ]
            ]);
    delete:
      operationId: delete-ip-list
      tags:
        - IP
      summary: 'Remove a list'
      description: |
        Removes the list; the account's next lookup no longer applies it. A list that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: listId
          in: path
          required: true
          schema:
            type: integer
          example: 7
          description: "The list's id from the index."
      responses:
        200:
          description: 'The list was removed.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
              example:
                success: true
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X DELETE https://spaw.co/api/v1/ip/lists/7 \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/lists/7', {
              method: 'DELETE',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.delete(
                'https://spaw.co/api/v1/ip/lists/7',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/lists/7');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'DELETE',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.delete_ip_list(7)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.deleteIpList(7);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->deleteIpList(7);
  '/api/v1/ip/asn/{asn}':
    get:
      operationId: get-ip-asn
      tags:
        - IP
      summary: 'Read an autonomous system and its prefixes'
      description: |
        The reverse of an address lookup's `asn` field: one autonomous system with the organisation operating it, the registry that allocated the number with the country and date it recorded, what the operator lists say about it (Spamhaus ASN-DROP, the curated satellite and mobile-carrier lists, tri-state like the address flags), and every prefix the DB-IP ASN database assigns to it — `prefix_count` per family is always whole, `prefixes` carries at most 500 per family and `truncated` says when it was cut. It is read from an index the server rebuilds from the ASN database once a month after it syncs; `sources` dates both.

        A number the database assigns no prefix to — a reserved or private number, a system that has never held address space, a typo — answers `404 ASN_NOT_FOUND` and costs nothing; a server that has not built the index yet answers `501 ASN_INDEX_UNAVAILABLE`, also free. It is a fact about the database, not the registry: the RIR statistics may well have allocated the number.
      x-spaw-auth: bearer
      x-spaw-billing: '1 credit per answered system, free for the next seven days for the same number. An unknown number (404) and a server without the index (501) cost nothing.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - INSUFFICIENT_CREDITS
        - ASN_NOT_FOUND
        - ASN_INDEX_UNAVAILABLE
        - RATE_LIMITED
      parameters:
        -
          name: asn
          in: path
          required: true
          schema:
            type: integer
          example: 15169
          description: 'The autonomous system number, without the AS prefix.'
      responses:
        200:
          description: 'The system, its facts and its prefixes.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/AsnResult'
                  meta:
                    $ref: '#/components/schemas/Meta'
              example:
                success: true
                data:
                  asn: 15169
                  org: 'Google LLC'
                  asn_type: hosting
                  registry: arin
                  registry_country: US
                  allocated_at: '2000-03-30'
                  is_blocklisted: false
                  blocklist: null
                  is_satellite: false
                  is_mobile: false
                  prefix_count:
                    ipv4: 157
                    ipv6: 28
                  prefixes:
                    ipv4:
                      - 8.8.4.0/24
                      - 8.8.8.0/24
                      - 8.34.208.0/20
                      - 8.35.192.0/20
                      - 34.4.0.0/14
                    ipv6:
                      - '2001:4860::/32'
                      - '2404:6800::/32'
                      - '2607:f8b0::/32'
                      - '2800:3f0::/32'
                      - '2a00:1450::/32'
                  truncated: false
                  sources:
                    -
                      dataset: asn-prefixes
                      version: '2026-09-12'
                    -
                      dataset: dbip-asn-lite
                      version: 2026-09
                    -
                      dataset: rir-allocations
                      version: '2026-09-12'
                    -
                      dataset: threat-blocklists
                      version: '2026-09-12'
                    -
                      dataset: satellite-asns
                      version: '2026-09-12'
                    -
                      dataset: mobile-carrier-asns
                      version: '2026-09-12'
                meta:
                  credits_used: 1
                  credits_remaining: 8
                  cache_hit: false
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        404:
          description: 'The database assigns no prefix to that number (`ASN_NOT_FOUND`). Nothing was charged.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: ASN_NOT_FOUND
                  message: 'No autonomous system numbered 999999 is in the index.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          $ref: '#/components/responses/RateLimited'
        501:
          description: 'The ASN prefix index has not been built on this server (`ASN_INDEX_UNAVAILABLE`). Nothing was charged.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: ASN_INDEX_UNAVAILABLE
                  message: 'The ASN prefix index has not been built on this server yet.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/ip/asn/15169 \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/asn/15169', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/ip/asn/15169',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/asn/15169');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.lookup_asn(15169)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.lookupAsn(15169);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->lookupAsn(15169);
  /api/v1/ip/range:
    get:
      operationId: lookup-ip-range
      tags:
        - IP
      summary: 'Read what the feeds say about a whole prefix'
      description: |
        One prefix at a time, without looking every address up: for each signal group — datacenter, VPN, relay, Tor, anycast, RIR allocation, threat blocklist, crawler and corporate proxy — whether any installed feed overlaps it, how many feed records intersect it (a Tor group counts exits inside the prefix, a datacenter group the provider ranges that touch it) and which named providers do; whose network and country the first and last addresses resolve to and whether the two ends agree; and the registry facts of the first address. `overlaps` is tri-state exactly like the address flags, and record counts are capped at 10,000 per feed with `truncated` saying so, so a count is a floor for a very wide prefix.

        The prefix travels as a query parameter, because a slash in a path segment has to be encoded and clients get that wrong: `GET /api/v1/ip/range?prefix=203.0.113.0/24`. A bare address is a /32 or /128, and the prefix comes back canonical (`8.8.8.9/24` as `8.8.8.0/24`). A prefix inside private or special-use space answers `reason: "reserved_range"` with every other field null, free.
      x-spaw-auth: bearer
      x-spaw-billing: '1 credit per answered prefix, free for the next seven days for the same prefix; a prefix inside reserved space costs nothing.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - INSUFFICIENT_CREDITS
        - RATE_LIMITED
      parameters:
        -
          name: prefix
          in: query
          required: true
          schema:
            type: string
            maxLength: 64
          example: 8.8.8.0/24
          description: 'An IPv4 or IPv6 CIDR prefix, or a bare address.'
      responses:
        200:
          description: 'The prefix, its two ends and what each feed group says.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/IpRangeResult'
                  meta:
                    $ref: '#/components/schemas/Meta'
              example:
                success: true
                data:
                  prefix: 8.8.8.0/24
                  version: 4
                  reason: null
                  first: 8.8.8.0
                  last: 8.8.8.255
                  addresses: 256
                  network:
                    asn: 15169
                    org: 'Google LLC'
                  network_agrees: true
                  country: US
                  country_agrees: true
                  registry: arin
                  registry_country: US
                  allocated_at: '2023-12-28'
                  signals:
                    datacenter:
                      overlaps: true
                      ranges: 1
                      providers: []
                    vpn:
                      overlaps: false
                      ranges: 0
                      providers: []
                    relay:
                      overlaps: false
                      ranges: 0
                      providers: []
                    tor:
                      overlaps: false
                      ranges: 0
                      providers: []
                    anycast:
                      overlaps: true
                      ranges: 1
                      providers: []
                    allocated:
                      overlaps: true
                      ranges: 1
                      providers: []
                    blocklist:
                      overlaps: false
                      ranges: 0
                      providers: []
                    attack_source:
                      overlaps: false
                      ranges: 0
                      providers: []
                    crawler:
                      overlaps: false
                      ranges: 0
                      providers: []
                    corporate_proxy:
                      overlaps: false
                      ranges: 0
                      providers: []
                  truncated: false
                  sources:
                    -
                      dataset: dbip-city-lite
                      version: 2026-09
                    -
                      dataset: dbip-asn-lite
                      version: 2026-09
                    -
                      dataset: datacenter-ranges
                      version: '2026-09-12'
                    -
                      dataset: vpn-ranges
                      version: '2026-09-12'
                    -
                      dataset: relay-ranges
                      version: '2026-09-12'
                    -
                      dataset: tor-exit-list
                      version: '2026-09-12'
                    -
                      dataset: anycast-ranges
                      version: '2026-09-12'
                    -
                      dataset: rir-allocations
                      version: '2026-09-12'
                    -
                      dataset: threat-blocklists
                      version: '2026-09-12'
                    -
                      dataset: crawler-ranges
                      version: '2026-09-12'
                    -
                      dataset: corporate-proxy-ranges
                      version: '2026-09-12'
                meta:
                  credits_used: 1
                  credits_remaining: 8
                  cache_hit: false
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/ip/range \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/ip/range', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/ip/range',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/ip/range');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.lookup_ip_range('8.8.8.0/24')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.lookupIpRange('8.8.8.0/24');
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->lookupIpRange('8.8.8.0/24');
  /api/v1/address:
    post:
      operationId: validate-address
      tags:
        - Address
      summary: 'Validate and enrich a postal address'
      description: |
        Reads a postal address against the country it is addressed to: that operator's own format (which parts it requires, how the lines are ordered, what shape the postcode takes), its naming for boxes and other non-street delivery points, the national address registers and postcode directories, the open company registers, and the branch lists mailbox operators publish themselves.

        Send the address either as one written `address`, the way it would appear on an envelope, or as the separated fields (`address_line1`, `address_line2`, `organization`, `building`, `dependent_locality`, `locality`, `administrative_area`, `postal_code`, `po_box`). Both spellings may be combined: a field sent beside a written address wins over what the parser read out of it. `country` (ISO 3166-1 alpha-2) is always required, because an address can only be measured against a country's own format; a code the format table does not cover answers `422`. A written address may end with the country's name, open with a care-of line, or put a building name above the street — each is read for what it is and reported in `changes` rather than mistaken for the town or the street.

        The answer gives the address broken into its parts, `formatted` as that country writes it, whether the postcode matches the country's pattern (`postal_code_valid`) and, where a directory is installed, whether it exists (`postcode_status`, denied only by a directory that lists every code), whether the town written on the address is one the directory publishes for that postcode (`locality_matches_postcode`) and whether the region is (`administrative_area_matches_postcode`). `address_type` says what kind of delivery point it is (`street`, `po_box`, `private_mailbox`, `general_delivery`, `parcel_locker`, `rural_route`, `military`, `bulk_mail`) and `address_type_source` says what settled it. An address that cannot stand as written answers `valid: false` with a `reason`: `empty_address`, `missing_country`, `unknown_country`, `missing_street`, `missing_locality`, `missing_administrative_area`, `missing_postal_code`, `invalid_postal_code` or `postcode_not_found`.

        Where a national address register is installed, `exists` answers whether the building itself is in it. It is `true` only on a premise match, `false` only when the register that answered has `register_coverage: "complete"` and does not carry this one, and `null` otherwise — including a register whose coverage is partial (the United States National Address Database is one, because states share their data voluntarily) and a register of streets alone. `match_level` says how close the register got (`premise`, `range`, `street`, `postcode`, `none`). A register that locates the building answers its position (`location_source: register`), and where it publishes a use or the units at the building, `building_use` and `is_multi_unit`. `premise_key` is the digest of the building your outcome reports and suppression list are filed under; `street_suggestion` names the street the register most likely meant when the typed one is not there. `confirmation` says, part by part, what stood behind the postcode, the street, the house number, the town and the region.

        The `risk_score` sums the weights of the signals in `risk_signals` (undeliverable 80, private_mailbox 60, placeholder 60, mail_drop 50, reported_returned 50, vacant 40, general_delivery 40, po_box 30, unit_unexpected 30, terminated_postcode 30, locality_mismatch 30, region_mismatch 30, not_in_register 20, unit_missing 20, street_not_found 20, number_out_of_range 20, bulk_mail 20; 60 and above is `high`, 30 and above `medium`), each with the dataset that produced it. Being a post-office box is not scored where a box is how that country receives mail; `not_in_register` fires only when `exists` is false, so a register whose coverage is partial never contributes to the score; and a town the directory lists elsewhere in the postcode's area, or a directory that publishes only each code's default place, never scores `locality_mismatch`.

        Every signal whose dataset is not installed answers `null`, which means "not evaluated" and never "no". `deliverability: true` asks a licensed partner whether post actually arrives at the address and whether the delivery point is a mail-receiving agency, vacant or residential; while no partner is enabled, `deliverability_checked` answers `false` and nothing extra is charged. `fields` keeps only the named keys of the answer, in the answer's own order; billing and `meta` are never narrowed by it.

        Every answer ends with a decision. `ok_to_ship` and `blocked_by` apply the account's shipping policy — the signals that block whenever they fire, a ceiling on the risk score, and whether a register must have confirmed the building (`GET`/`PUT`/`DELETE /api/v1/address/policy`; the defaults block above 60 and on undeliverable, placeholder and vacant) — in a published order: an address that cannot stand as written is blocked whatever the policy says (`blocked_by: invalid`), then the first blocked signal in the weights' order, then `risk_score`, then `unconfirmed`. Batch, bulk, monitors and the browser endpoint apply the same policy.

        Two signals read the input against itself and the register. `looks_placeholder` is true when the whole street name or the whole town is what somebody types to get past a form — a test word, a keyboard run, a refusal, a name with no vowel — and never when a real street merely contains one of the words; the `placeholder` weight is 60. `unit_check` reads the unit written against the units the register lists at the building: `missing` (several units listed, none written, weight 20) or `unexpected` (a flat or suite written at a single residence, weight 30); a suite at a non-residential building is ordinary and answers null, and a private-mailbox number never counts as a unit. `seen_by_you` counts your own account's lookups of the same address inside the last seven days, under the premise digest, and never anybody else's. `formatted_lines` is `formatted` as an array, `postal_code_formatted` the postcode as its operator prints it, and `formatted_local` the address in the country's own script where the input was written in one (Japan, China, Korea).

        Five documented test addresses answer a fixed result each, free, with no repeat marker and nothing stored: 1 to 5 Spaw Test Street, Springfield, IL 99999 stand for a valid low-risk address, a mail drop, a building a complete register lacks, a terminated postcode, and — written as `5 Spaw Test Street PMB 5` — a private mailbox. The published sandbox key answers them too.

        The product is about places, not people: no field says who lives or works at an address, and none is ever added.
      x-spaw-auth: bearer
      x-spaw-billing: '1 credit for a fresh lookup that answers `valid` true; addresses that cannot stand as written and 7-day repeats are free. Two spellings of one address share the repeat marker, so they are charged once. Asking for the deliverability check adds 3 credits only when the partner actually answered.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - INSUFFICIENT_CREDITS
        - RATE_LIMITED
        - SANDBOX_VALUE_NOT_ALLOWED
        - SANDBOX_UNAVAILABLE
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - country
              properties:
                address:
                  type:
                    - string
                    - 'null'
                  maxLength: 500
                  description: 'The whole address written as it would be on an envelope, with the lines separated by commas or newlines. Required unless `address_line1` is sent.'
                address_line1:
                  type:
                    - string
                    - 'null'
                  maxLength: 255
                  description: 'The street line — house number and street name, in the order that country writes them. Required unless `address` is sent.'
                address_line2:
                  type:
                    - string
                    - 'null'
                  maxLength: 255
                  description: 'A second line — a flat, suite or unit, or a dependent locality. A value that reads as a unit is moved into `unit`.'
                organization:
                  type:
                    - string
                    - 'null'
                  maxLength: 200
                  description: 'The company or institution the address belongs to.'
                building:
                  type:
                    - string
                    - 'null'
                  maxLength: 100
                  description: 'A building named instead of numbered (Rose Cottage, Kemp House). Left out, a building name above the street line of a written address is separated by the parser and reported in `changes`.'
                dependent_locality:
                  type:
                    - string
                    - 'null'
                  maxLength: 100
                  description: 'A neighbourhood, village or district written below the town.'
                locality:
                  type:
                    - string
                    - 'null'
                  maxLength: 100
                  description: 'The town or city.'
                administrative_area:
                  type:
                    - string
                    - 'null'
                  maxLength: 100
                  description: 'The state, province or region, as a code or a name; it is folded to the code that country uses where one exists.'
                postal_code:
                  type:
                    - string
                    - 'null'
                  maxLength: 32
                  description: 'The postcode or ZIP code.'
                po_box:
                  type:
                    - string
                    - 'null'
                  maxLength: 64
                  description: 'A post-office box written on its own, when it is not part of the address lines.'
                country:
                  type: string
                  maxLength: 2
                  description: 'An ISO 3166-1 alpha-2 country code the postal format table covers (`US`, `GB`, `DE`). Case-insensitive.'
                deliverability:
                  type: boolean
                  default: false
                  description: 'Ask the licensed partner whether post actually arrives at the address. While a partner is configured it reserves the premium credits up front and charges them only when the partner answered; while none is, nothing is reserved and nothing is charged.'
                fields:
                  type:
                    - string
                    - 'null'
                  maxLength: 1000
                  description: "A comma-separated list of the answer's keys to keep (`formatted,risk_score,confirmation`). Applied after billing; unknown names are ignored, `sources` is dropped unless named, and `meta` is never narrowed."
            example:
              address: '1600 Amphitheatre Parkway, Mountain View, CA 94043'
              country: US
      responses:
        200:
          description: 'The address as the pipeline reads it; `valid: false` with a `reason` for an address that cannot stand as written.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/AddressResult'
                  meta:
                    $ref: '#/components/schemas/Meta'
              example:
                success: true
                data:
                  valid: true
                  reason: null
                  country: US
                  country_name: 'United States'
                  continent: NA
                  is_eu: false
                  is_eea: false
                  privacy_regime: ccpa
                  formatted: |-
                    1600 Amphitheatre Parkway
                    MOUNTAIN VIEW, CA 94043
                  formatted_lines:
                    - '1600 Amphitheatre Parkway'
                    - 'MOUNTAIN VIEW, CA 94043'
                  formatted_local: null
                  organization: null
                  building: null
                  street: 'Amphitheatre Parkway'
                  house_number: '1600'
                  unit: null
                  po_box: null
                  dependent_locality: null
                  locality: 'Mountain View'
                  administrative_area: CA
                  postal_code: '94043'
                  postal_code_formatted: '94043'
                  postal_code_valid: true
                  postal_code_type: zip
                  postcode_status: live
                  locality_matches_postcode: true
                  administrative_area_matches_postcode: true
                  address_type: street
                  address_type_source: address_rules
                  is_po_box: false
                  is_private_mailbox: null
                  exists: true
                  match_level: premise
                  register: us-national-address-database
                  register_coverage: partial
                  premise_key: 239f04782171457b
                  building_use: commercial
                  is_multi_unit: null
                  unit_check: null
                  street_suggestion: null
                  latitude: 37.42201
                  longitude: -122.08409
                  location_source: register
                  companies_registered: null
                  is_mail_drop: false
                  mail_drop_provider: null
                  mail_drop_source: null
                  deliverability_checked: false
                  is_deliverable: null
                  is_cmra: null
                  is_vacant: null
                  is_residential: null
                  confirmation:
                    postal_code: postcode_directory
                    street: register
                    house_number: register
                    locality: postcode_directory
                    administrative_area: postcode_directory
                  changes: []
                  looks_placeholder: false
                  seen_by_you:
                    count: 0
                    first_seen_at: null
                    window_days: 7
                  risk_score: 0
                  risk_level: low
                  risk_signals: []
                  ok_to_ship: true
                  blocked_by: null
                  sources:
                    -
                      dataset: address-formats
                      version: '2026-09-05'
                    -
                      dataset: address-rules
                      version: '2026-09-05'
                    -
                      dataset: us-national-address-database
                      version: '2026-08-14'
                    -
                      dataset: geonames-postal-codes
                      version: '2026-09-16'
                    -
                      dataset: us-tiger-address-ranges
                      version: '2025'
                meta:
                  credits_used: 1
                  credits_remaining: 9
                  cache_hit: false
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/address \
              -H "Authorization: Bearer sk_sandbox_spaw" \
              -H "Content-Type: application/json" \
              -d '{
              "address": "1600 Amphitheatre Parkway, Mountain View, CA 94043",
              "country": "US"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_sandbox_spaw',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "address": "1600 Amphitheatre Parkway, Mountain View, CA 94043",
                "country": "US"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/address',
                headers={'Authorization': 'Bearer sk_sandbox_spaw'},
                json={
                    'address': '1600 Amphitheatre Parkway, Mountain View, CA 94043',
                    'country': 'US'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_sandbox_spaw', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'address' => '1600 Amphitheatre Parkway, Mountain View, CA 94043',
                    'country' => 'US'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_sandbox_spaw')
            result = client.verify_address('1600 Amphitheatre Parkway, Mountain View, CA 94043', country='US')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_sandbox_spaw' });
            const result = await spaw.verifyAddress('1600 Amphitheatre Parkway, Mountain View, CA 94043', {
                country: 'US'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_sandbox_spaw');
            $result = $spaw->verifyAddress('1600 Amphitheatre Parkway, Mountain View, CA 94043', country: 'US');
  /api/v1/address/batch:
    post:
      operationId: lookup-address-batch
      tags:
        - Address
      summary: 'Validate up to 100 addresses in one call'
      description: |
        Runs the single-address lookup for every item, in input order, under the exact single-lookup billing rules. Every answer comes from the installed datasets, so no item waits on a network call. `data.results[]` mirrors the single-address response per item and each item's `meta` carries `credits_used` and `cache_hit`.

        Each item is either a written address as a plain string, or an object of the same fields the single lookup takes. An item may name its own `country`, which wins over the list-level one; an item left with no country at all is answered `missing_country`, free, rather than refused.

        Repeats inside the list are looked up once and answer as cache hits — the repeat marker is keyed by the address as the pipeline folds it, so two spellings of one address count as one. The list is walked in order and stops where the balance would run out: running out of credits mid-batch returns the paid partial results with `meta.stopped_reason: "insufficient_credits"` and `processed` lower than `requested`, never discarding billed work. Only a batch whose first lookup is refused answers the typed `402`. A key with a daily credit cap stops the list the same way once the cap is spent: the addresses already paid for come back with `meta.stopped_reason: "key_spend_cap"`, and a call that starts with the cap already spent answers `429 KEY_SPEND_CAP_REACHED` and runs nothing.

        A batch counts as one request against the rate limit. `deliverability` asks the licensed partner about every item; the base and premium credits are gated per item before the lookup, so no paid query is spent on an address that could not be charged.

        Batches honour the account's postal suppression list: an address on it is answered from the list without being read again, free, with `meta.suppressed: true` on that item.

        Every item carries `index`, its zero-based position in the list you sent, and `input`, the address as you sent it: an object holding the parts you actually gave — `address` for a written line, the separated fields for an object — plus the country the item was measured against. It is echoed, never parsed, so it is not the answer's `formatted`. A batch stopped early by `stopped_reason` answers only a prefix of the list, so those two are what line an answer up with the row it came from.
      x-spaw-auth: bearer
      x-spaw-billing: 'Each address bills like a single lookup: 1 credit for a fresh answer that stands as written; addresses that cannot, and 7-day repeats, are free. The call stops cleanly where the balance ends.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - INSUFFICIENT_CREDITS
        - KEY_SPEND_CAP_REACHED
        - RATE_LIMITED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - addresses
              properties:
                addresses:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    type:
                      - string
                      - object
                    maxLength: 500
                    properties:
                      address:
                        type:
                          - string
                          - 'null'
                        maxLength: 500
                        description: 'The whole address written as it would be on an envelope.'
                      address_line1:
                        type:
                          - string
                          - 'null'
                        maxLength: 255
                        description: 'The street line, when the address arrives separated.'
                      address_line2:
                        type:
                          - string
                          - 'null'
                        maxLength: 255
                      organization:
                        type:
                          - string
                          - 'null'
                        maxLength: 200
                      building:
                        type:
                          - string
                          - 'null'
                        maxLength: 100
                      dependent_locality:
                        type:
                          - string
                          - 'null'
                        maxLength: 100
                      locality:
                        type:
                          - string
                          - 'null'
                        maxLength: 100
                      administrative_area:
                        type:
                          - string
                          - 'null'
                        maxLength: 100
                      postal_code:
                        type:
                          - string
                          - 'null'
                        maxLength: 32
                      po_box:
                        type:
                          - string
                          - 'null'
                        maxLength: 64
                      country:
                        type:
                          - string
                          - 'null'
                        maxLength: 2
                        description: "This item's own country. Wins over the list-level one."
                  description: '1 to 100 addresses. Each item is a written address as a string, or an object of the same fields the single lookup takes.'
                country:
                  type:
                    - string
                    - 'null'
                  maxLength: 2
                  description: 'An ISO 3166-1 alpha-2 code applied to every item that does not name its own.'
                deliverability:
                  type: boolean
                  default: false
                  description: 'Ask the licensed partner about every address. While no partner is enabled it answers `deliverability_checked` false at no extra cost.'
            example:
              addresses:
                - '1600 Amphitheatre Parkway, Mountain View, CA 94043'
                -
                  address_line1: '221B Baker Street'
                  locality: London
                  postal_code: 'NW1 6XE'
                  country: GB
                - 94043
              country: US
      responses:
        200:
          description: 'One result per processed address, plus the batch totals.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AddressBatchResponse'
              example:
                success: true
                data:
                  results:
                    -
                      index: 0
                      input:
                        country: US
                        address: '1600 Amphitheatre Parkway, Mountain View, CA 94043'
                      data:
                        valid: true
                        reason: null
                        country: US
                        street: 'Amphitheatre Parkway'
                        house_number: '1600'
                        locality: 'Mountain View'
                        administrative_area: CA
                        postal_code: '94043'
                        address_type: street
                        risk_score: 0
                        risk_level: low
                      meta:
                        credits_used: 1
                        cache_hit: false
                    -
                      index: 1
                      input:
                        country: GB
                        address_line1: '221B Baker Street'
                        locality: London
                        postal_code: 'NW1 6XE'
                      data:
                        valid: true
                        reason: null
                        country: GB
                        street: 'Baker Street'
                        house_number: 221B
                        locality: London
                        postal_code: 'NW1 6XE'
                        address_type: street
                        risk_score: 0
                        risk_level: low
                      meta:
                        credits_used: 1
                        cache_hit: false
                    -
                      index: 2
                      input:
                        country: US
                        address: '94043'
                      data:
                        valid: false
                        reason: missing_street
                        country: US
                        street: null
                        postal_code: '94043'
                        risk_score: null
                        risk_level: null
                      meta:
                        credits_used: 0
                        cache_hit: false
                meta:
                  requested: 3
                  processed: 3
                  credits_used: 2
                  credits_remaining: 8
                  stopped_reason: null
                  request_id: req_01m1kgdrtqdvwnks99vfgx2rcw
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/address/batch \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "addresses": [
                "1600 Amphitheatre Parkway, Mountain View, CA 94043",
                {
                  "address_line1": "221B Baker Street",
                  "locality": "London",
                  "postal_code": "NW1 6XE",
                  "country": "GB"
                },
                94043
              ],
              "country": "US"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/batch', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "addresses": [
                  "1600 Amphitheatre Parkway, Mountain View, CA 94043",
                  {
                    "address_line1": "221B Baker Street",
                    "locality": "London",
                    "postal_code": "NW1 6XE",
                    "country": "GB"
                  },
                  94043
                ],
                "country": "US"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/address/batch',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'addresses': [
                        '1600 Amphitheatre Parkway, Mountain View, CA 94043',
                        {
                            'address_line1': '221B Baker Street',
                            'locality': 'London',
                            'postal_code': 'NW1 6XE',
                            'country': 'GB'
                        },
                        94043
                    ],
                    'country': 'US'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/batch');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'addresses' => [
                        '1600 Amphitheatre Parkway, Mountain View, CA 94043',
                        [
                            'address_line1' => '221B Baker Street',
                            'locality' => 'London',
                            'postal_code' => 'NW1 6XE',
                            'country' => 'GB'
                        ],
                        94043
                    ],
                    'country' => 'US'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.verify_addresses([
                '1600 Amphitheatre Parkway, Mountain View, CA 94043',
                {
                    'address_line1': '221B Baker Street',
                    'locality': 'London',
                    'postal_code': 'NW1 6XE',
                    'country': 'GB'
                },
                94043
            ], country='US')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.verifyAddresses([
                '1600 Amphitheatre Parkway, Mountain View, CA 94043',
                {
                    address_line1: '221B Baker Street',
                    locality: 'London',
                    postal_code: 'NW1 6XE',
                    country: 'GB'
                },
                94043
            ], {
                country: 'US'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->verifyAddresses([
                '1600 Amphitheatre Parkway, Mountain View, CA 94043',
                [
                    'address_line1' => '221B Baker Street',
                    'locality' => 'London',
                    'postal_code' => 'NW1 6XE',
                    'country' => 'GB'
                ],
                94043
            ], country: 'US');
  /api/v1/address/bulk:
    get:
      operationId: list-address-bulk-jobs
      tags:
        - Address
      summary: "List the account's bulk address lookup runs"
      description: |
        Every bulk run this account has started, newest first, 100 per page. A job id is handed out once, at creation, and everything about a run hangs off it — its counts, its results, its cancel and resume calls — so this is how a caller that lost one finds the run again, and how a dashboard lists what is still in flight.

        `status`, `since` and `until` are optional and combine; a bare date in `until` means the whole of that day, and an unknown status or an unparseable date is a `422` rather than an empty page. The rows are the same job objects `GET /api/v1/address/bulk/{jobId}` answers. The webhook secret is never among them: it is returned once, at creation.

        Runs are kept as long as the lookup history, and `meta.retention_days` says how long that is — a run older than that is deleted with its files and cannot be listed.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      parameters:
        -
          name: status
          in: query
          required: false
          schema:
            type: string
            enum:
              - queued
              - processing
              - completed
              - failed
              - cancelled
          example: completed
          description: 'Only runs in this status.'
        -
          name: since
          in: query
          required: false
          schema:
            type: string
          example: '2026-09-01'
          description: 'Only runs created at or after this date or timestamp.'
        -
          name: until
          in: query
          required: false
          schema:
            type: string
          example: '2026-09-08'
          description: 'Only runs created at or before this. A bare date means the whole of that day.'
        -
          name: page
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            default: 1
          example: 1
          description: 'The page to return.'
      responses:
        200:
          description: 'One page of runs, newest first.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      jobs:
                        type: array
                        items:
                          $ref: '#/components/schemas/AddressBulkJob'
                  meta:
                    type: object
                    properties:
                      page:
                        type: integer
                      per_page:
                        type: integer
                      total:
                        type: integer
                      retention_days:
                        type: integer
                        description: 'How long a run and its files are kept before they are deleted.'
              example:
                success: true
                data:
                  jobs:
                    -
                      id: 17
                      status: completed
                      total: 900
                      duplicate_count: 4
                      processed: 900
                      valid: 812
                      invalid: 88
                      low: 760
                      medium: 120
                      high: 20
                      credits_used: 812
                      stopped_reason: null
                      cancel_requested: false
                      country: GB
                      deliverability: false
                      webhook_status: null
                      webhook_detail: null
                      created_at: '2026-09-05T11:30:00+00:00'
                      finished_at: '2026-09-05T11:31:40+00:00'
                meta:
                  page: 1
                  per_page: 100
                  total: 1
                  retention_days: 30
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/address/bulk \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/bulk', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/address/bulk',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/bulk');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.list_address_bulk_jobs()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.listAddressBulkJobs();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->listAddressBulkJobs();
    post:
      operationId: create-address-bulk-job
      tags:
        - Address
      summary: 'Queue a bulk address lookup run'
      description: |
        Queues up to 100,000 addresses and processes them in the background. Creation answers `202` with the job's id and status. Poll `GET /api/v1/address/bulk/{jobId}` for progress, then download the result CSV from `GET /api/v1/address/bulk/{jobId}/results`: one row per input address with every response field as a column. Jobs and their files are kept for 30 days.

        Each item is a written address as a plain string, or an object of the same fields the single lookup takes, and may name its own `country`, which wins over the list-level one; an item left with no country is answered `missing_country`, free. Rows repeating an address already in the list are looked up once and counted in `duplicate_count` — the repeat marker folds case, punctuation and street-suffix wording away, so two spellings of one address count as one. A run that exhausts the balance stops with `stopped_reason: "insufficient_credits"` and keeps everything answered so far. Because nothing about an address lookup is stored, the input list and the result file are the only copies and both are deleted with the job.

        With a `webhook_url`, the finish (`completed`, `failed`, or `cancelled`) is POSTed there as `{ "event": "bulk_address_job.finished", "job": { … } }`, signed with `webhook_secret` exactly like bulk email, phone and IP runs: `X-Spaw-Signature-V2` carries `t={unix seconds},v2={lowercase hex}`, the HMAC-SHA256 of `v2:{t}:{raw body}`, and a receiver should refuse a `t` more than five minutes from its own clock so a captured delivery cannot be replayed at it later. The original `X-Spaw-Signature`, over the body alone, is sent beside it until 1 March 2027. Send an `Idempotency-Key` header to make creation safe to retry: a repeat of the same request answers the job the first attempt created, as `200` with `Idempotent-Replayed: true`; the same key with a different request answers `409 IDEMPOTENCY_KEY_REUSED`.

        ### Reading a list before you start it

        Send `preview: true` with the same body — or `csv` with a raw CSV instead of the item list — and the call answers `200` with `data.draft` instead of queueing anything: what the list was read as, which column filled which field and whether a heading named it or we guessed, the first rows as parsed, the repeats, the balance, and `credits_upper_bound`. Nothing is charged.

        `credits_upper_bound` is a CEILING, not a quote. Rows come back free for reasons no preview can know in advance — a repeat inside the seven-day window, an entry on your suppression list, a verdict that is not charged for, a provider outage — so a run usually costs less than the ceiling and never more. It is priced against the mapping in play, so correcting a column at start re-prices the run rather than holding you to the figure the guess produced — ask for the draft again after a correction if you want the new number. What it actually cost is `credits_used` on the finished job.

        Start it with `{"draft_id": <id>}`, adding `mapping` to correct a column we read wrongly. Starting consumes the draft; one left unstarted is deleted, with the rows it holds, after `retention_hours`.
      x-spaw-auth: bearer
      x-spaw-billing: '`preview: true` is free and queues nothing. A started run bills each row like a single lookup as it is processed; addresses that cannot stand as written, repeats inside the run and 7-day repeats are free, so a run costs at most the `credits_upper_bound` the preview answered and usually less.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - IDEMPOTENCY_KEY_REUSED
        - RATE_LIMITED
      parameters:
        -
          name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            minLength: 1
            maxLength: 128
          description: 'A value unique to this request, so a retry after a timeout answers the same job instead of queueing a second one.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                addresses:
                  type: array
                  minItems: 1
                  maxItems: 100000
                  items:
                    type:
                      - string
                      - object
                    maxLength: 500
                    properties:
                      address:
                        type:
                          - string
                          - 'null'
                        maxLength: 500
                        description: 'The whole address written as it would be on an envelope.'
                      address_line1:
                        type:
                          - string
                          - 'null'
                        maxLength: 255
                        description: 'The street line, when the address arrives separated.'
                      address_line2:
                        type:
                          - string
                          - 'null'
                        maxLength: 255
                      organization:
                        type:
                          - string
                          - 'null'
                        maxLength: 200
                      building:
                        type:
                          - string
                          - 'null'
                        maxLength: 100
                      dependent_locality:
                        type:
                          - string
                          - 'null'
                        maxLength: 100
                      locality:
                        type:
                          - string
                          - 'null'
                        maxLength: 100
                      administrative_area:
                        type:
                          - string
                          - 'null'
                        maxLength: 100
                      postal_code:
                        type:
                          - string
                          - 'null'
                        maxLength: 32
                      po_box:
                        type:
                          - string
                          - 'null'
                        maxLength: 64
                      country:
                        type:
                          - string
                          - 'null'
                        maxLength: 2
                        description: "This item's own country. Wins over the list-level one."
                  description: '1 to 100,000 addresses. Each item is a written address as a string, or an object of the same fields the single lookup takes. Required unless the body carries `csv` with `preview: true`, or `draft_id`.'
                country:
                  type:
                    - string
                    - 'null'
                  maxLength: 2
                  description: 'An ISO 3166-1 alpha-2 code applied to every row that does not name its own.'
                deliverability:
                  type: boolean
                  default: false
                  description: 'Ask the licensed partner about every row. While no partner is enabled it answers `deliverability_checked` false at no extra cost; when one is, each row that answers is charged the premium credits on top of the base credit.'
                preview:
                  type: boolean
                  default: false
                  description: 'Read the list and answer `data.draft` — what we made of it, the repeats, the ceiling on the cost and the balance — with nothing queued and nothing billed. Start it afterwards with `draft_id`.'
                csv:
                  type:
                    - string
                    - 'null'
                  maxLength: 5000000
                  description: "A raw CSV to read instead of the item list, with the column mapping answered back. Preview only, on purpose: a CSV's columns have to be worked out, and working them out and billing for the answer in one request is what the preview exists to stop."
                draft_id:
                  type:
                    - integer
                    - 'null'
                  description: 'Start the run a preview answered with. The rows and the options come from the draft, so nothing else in the body applies except `mapping`. A draft can be started once; an `Idempotency-Key` still makes the call safe to retry.'
                mapping:
                  type:
                    - object
                    - 'null'
                  additionalProperties:
                    type: string
                  description: 'Corrects the draft''s column mapping, as column index to field — {"1": "address_line1", "2": "postal_code"}. It REPLACES the detected mapping rather than merging into it, so a column can be taken off a field as well as put on one. Goes with `draft_id`, and only for a draft read from a `csv`.'
                webhook_url:
                  type:
                    - string
                    - 'null'
                  format: uri
                  description: 'An https URL to notify when the run finishes.'
            example:
              addresses:
                - '1600 Amphitheatre Parkway, Mountain View, CA 94043'
                -
                  address_line1: '221B Baker Street'
                  locality: London
                  postal_code: 'NW1 6XE'
                  country: GB
              country: US
              webhook_url: 'https://example.com/hooks/spaw'
      responses:
        202:
          description: 'The job was queued.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        allOf:
                          -
                            $ref: '#/components/schemas/AddressBulkJob'
                          -
                            type: object
                            properties:
                              webhook_secret:
                                type:
                                  - string
                                  - 'null'
                                description: 'Returned once. Verify webhook signatures with it.'
              example:
                success: true
                data:
                  job:
                    id: 918
                    status: queued
                    total: 2
                    duplicate_count: 0
                    processed: 0
                    valid: 0
                    invalid: 0
                    low: 0
                    medium: 0
                    high: 0
                    credits_used: 0
                    stopped_reason: null
                    cancel_requested: false
                    country: US
                    deliverability: false
                    webhook_status: null
                    created_at: '2026-09-05T10:12:44+00:00'
                    finished_at: null
                    webhook_secret: '8fJ2…40 characters…Qk1'
        200:
          description: 'Either a preview — `preview: true` answers `data.draft` and queues nothing — or a retry with an `Idempotency-Key` already used for this exact request, which answers `data.job` with `Idempotent-Replayed: true`.'
          headers:
            Idempotent-Replayed:
              schema:
                type: string
                enum:
                  - 'true'
              description: 'Present only on a replayed creation.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      draft:
                        $ref: '#/components/schemas/BulkDraft'
                      job:
                        $ref: '#/components/schemas/AddressBulkJob'
              example:
                success: true
                data:
                  job:
                    id: 918
                    status: processing
                    total: 2
                    duplicate_count: 0
                    processed: 1
                    valid: 1
                    invalid: 0
                    low: 1
                    medium: 0
                    high: 0
                    credits_used: 1
                    stopped_reason: null
                    cancel_requested: false
                    country: US
                    deliverability: false
                    webhook_status: null
                    created_at: '2026-09-05T10:12:44+00:00'
                    finished_at: null
        401:
          $ref: '#/components/responses/Unauthenticated'
        409:
          description: 'The `Idempotency-Key` was already used for a different list, country, deliverability flag or webhook URL.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: IDEMPOTENCY_KEY_REUSED
                  message: 'This Idempotency-Key was already used for a different request.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/address/bulk \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "addresses": [
                "1600 Amphitheatre Parkway, Mountain View, CA 94043",
                {
                  "address_line1": "221B Baker Street",
                  "locality": "London",
                  "postal_code": "NW1 6XE",
                  "country": "GB"
                }
              ],
              "country": "US",
              "webhook_url": "https://example.com/hooks/spaw"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/bulk', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "addresses": [
                  "1600 Amphitheatre Parkway, Mountain View, CA 94043",
                  {
                    "address_line1": "221B Baker Street",
                    "locality": "London",
                    "postal_code": "NW1 6XE",
                    "country": "GB"
                  }
                ],
                "country": "US",
                "webhook_url": "https://example.com/hooks/spaw"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/address/bulk',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'addresses': [
                        '1600 Amphitheatre Parkway, Mountain View, CA 94043',
                        {
                            'address_line1': '221B Baker Street',
                            'locality': 'London',
                            'postal_code': 'NW1 6XE',
                            'country': 'GB'
                        }
                    ],
                    'country': 'US',
                    'webhook_url': 'https://example.com/hooks/spaw'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/bulk');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'addresses' => [
                        '1600 Amphitheatre Parkway, Mountain View, CA 94043',
                        [
                            'address_line1' => '221B Baker Street',
                            'locality' => 'London',
                            'postal_code' => 'NW1 6XE',
                            'country' => 'GB'
                        ]
                    ],
                    'country' => 'US',
                    'webhook_url' => 'https://example.com/hooks/spaw'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.create_address_bulk_job([
                '1600 Amphitheatre Parkway, Mountain View, CA 94043',
                {
                    'address_line1': '221B Baker Street',
                    'locality': 'London',
                    'postal_code': 'NW1 6XE',
                    'country': 'GB'
                }
            ], country='US', webhook_url='https://example.com/hooks/spaw')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.createAddressBulkJob([
                '1600 Amphitheatre Parkway, Mountain View, CA 94043',
                {
                    address_line1: '221B Baker Street',
                    locality: 'London',
                    postal_code: 'NW1 6XE',
                    country: 'GB'
                }
            ], {
                country: 'US',
                webhookUrl: 'https://example.com/hooks/spaw'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->createAddressBulkJob([
                '1600 Amphitheatre Parkway, Mountain View, CA 94043',
                [
                    'address_line1' => '221B Baker Street',
                    'locality' => 'London',
                    'postal_code' => 'NW1 6XE',
                    'country' => 'GB'
                ]
            ], country: 'US', webhookUrl: 'https://example.com/hooks/spaw');
  '/api/v1/address/bulk/{jobId}':
    get:
      operationId: get-address-bulk-job
      tags:
        - Address
      summary: 'Poll a bulk address job'
      description: |
        The job's current status and running counts. `status` moves from `queued` to `processing` and ends in `completed`, `failed`, or `cancelled`; `processed`, the `valid` and `invalid` counts and the `low`, `medium` and `high` risk counts advance as chunks finish. A job that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'The job.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        $ref: '#/components/schemas/AddressBulkJob'
              example:
                success: true
                data:
                  job:
                    id: 918
                    status: completed
                    total: 2
                    duplicate_count: 0
                    processed: 2
                    valid: 2
                    invalid: 0
                    low: 2
                    medium: 0
                    high: 0
                    credits_used: 2
                    stopped_reason: null
                    cancel_requested: false
                    country: US
                    deliverability: false
                    webhook_status: delivered
                    created_at: '2026-09-05T10:12:44+00:00'
                    finished_at: '2026-09-05T10:12:46+00:00'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/address/bulk/{jobId} \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/bulk/{jobId}', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/address/bulk/{jobId}',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/bulk/{jobId}');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.get_address_bulk_job(job_id)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.getAddressBulkJob(jobId);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->getAddressBulkJob($jobId);
  '/api/v1/address/bulk/{jobId}/cancel':
    post:
      operationId: cancel-address-bulk-job
      tags:
        - Address
      summary: 'Cancel a bulk address job'
      description: |
        Asks a queued or running job to stop at its next checkpoint. Everything answered so far stays billed and stays in the result file. A job that already finished answers `409 JOB_ALREADY_FINISHED`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - JOB_ALREADY_FINISHED
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
      responses:
        202:
          description: 'Cancellation was requested.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        $ref: '#/components/schemas/AddressBulkJob'
              example:
                success: true
                data:
                  job:
                    id: 918
                    status: processing
                    total: 20000
                    duplicate_count: 12
                    processed: 4000
                    valid: 3700
                    invalid: 300
                    low: 3100
                    medium: 600
                    high: 0
                    credits_used: 3700
                    stopped_reason: null
                    cancel_requested: true
                    country: null
                    deliverability: false
                    webhook_status: null
                    created_at: '2026-09-05T10:12:44+00:00'
                    finished_at: null
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        409:
          description: 'The job already finished.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: JOB_ALREADY_FINISHED
                  message: 'This job has already finished.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/address/bulk/{jobId}/cancel \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/bulk/{jobId}/cancel', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/address/bulk/{jobId}/cancel',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/bulk/{jobId}/cancel');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.cancel_address_bulk_job(job_id)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.cancelAddressBulkJob(jobId);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->cancelAddressBulkJob($jobId);
  '/api/v1/address/bulk/{jobId}/resume':
    post:
      operationId: resume-address-bulk-job
      tags:
        - Address
      summary: 'Resume a bulk job that ran out of credits'
      description: |
        Runs a job that stopped when the credit balance ran out again, from the top of the list it was given, instead of asking you to upload it a second time. Rows looked up in the last seven days come back as free repeats, so only the rows the stopped run never reached are charged.

        The job keeps its id, its webhook and its cumulative `credits_used`, which is the real spend on the account. `processed` and the verdict counts restart at zero: the part files they were counted from are deleted when a run settles, so the resumed run rebuilds them, and the result file the stopped run left is replaced as the rows come back.

        Only a run whose `stopped_reason` is `insufficient_credits` and whose `status` has settled can be resumed; anything else answers `409 JOB_NOT_RESUMABLE`, and so does a second resume of a run that is already going again — claiming the run is one conditional write, so two calls can never queue it twice. A run whose list has passed out of the retention window answers `409 JOB_INPUT_EXPIRED`. An empty balance answers `402 INSUFFICIENT_CREDITS` rather than queueing a run that would stop again on the first row it never reached.
      x-spaw-auth: bearer
      x-spaw-billing: 'Free to call. The resumed run bills the rows it reaches exactly as the first one did, and a row answered in the last seven days is a free repeat.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - JOB_NOT_RESUMABLE
        - JOB_INPUT_EXPIRED
        - INSUFFICIENT_CREDITS
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
      responses:
        202:
          description: 'The list is queued again.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        $ref: '#/components/schemas/AddressBulkJob'
              example:
                success: true
                data:
                  job:
                    id: 918
                    status: queued
                    total: 20000
                    duplicate_count: 12
                    processed: 0
                    valid: 0
                    invalid: 0
                    low: 0
                    medium: 0
                    high: 0
                    credits_used: 3700
                    stopped_reason: null
                    cancel_requested: false
                    country: null
                    deliverability: false
                    webhook_status: null
                    created_at: '2026-09-05T10:12:44+00:00'
                    finished_at: null
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        404:
          $ref: '#/components/responses/NotFound'
        409:
          description: 'The run is not in a state a resume can recover (`JOB_NOT_RESUMABLE`), or its list is no longer stored (`JOB_INPUT_EXPIRED`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: JOB_NOT_RESUMABLE
                  message: 'Only a run that stopped because the credit balance ran out can be resumed, and only once it has finished settling.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/address/bulk/{jobId}/resume \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/bulk/{jobId}/resume', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/address/bulk/{jobId}/resume',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/bulk/{jobId}/resume');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.resume_address_bulk_job(job_id)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.resumeAddressBulkJob(jobId);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->resumeAddressBulkJob($jobId);
  '/api/v1/address/bulk/{jobId}/webhook/redeliver':
    post:
      operationId: redeliver-address-bulk-webhook
      tags:
        - Address
      summary: "Send a finished run's completion webhook again"
      description: |
        POSTs a finished run's completion webhook to its `webhook_url` a second time. This is for the case the webhook exists to cover: the run finished, the delivery went out, your endpoint was down for the few minutes it was tried, and the message is gone. `webhook_status` and `webhook_detail` tell you that happened; this is how you ask for it again, instead of falling back to polling — which is the thing the webhook was set up to avoid.

        **The body is the same body.** It is rebuilt from the run's own row rather than replayed from a stored blob, and every field it carries — `status`, `processed`, the row counts, `credits_used`, `stopped_reason`, `finished_at` — is frozen once a run has settled. The bytes are the ones the first delivery carried, so reconciling a redelivery against the original compares equals. (A run that is resumed later settles again and posts a new completion webhook of its own; a redelivery always repeats the most recent one.)

        **The signature is new, and that is correct.** Each delivery is signed as it is sent, so `X-Spaw-Signature-V2` carries a later `t` and a different `v2` digest from the first attempt. That is required rather than incidental: a receiver refuses anything whose `t` is more than five minutes from its own clock, so a signature copied from the original would be refused on arrival. Verify a redelivery exactly as you verify any other delivery. The untimestamped `X-Spaw-Signature` is the HMAC of the body alone, so it *is* identical to the first attempt's, until it stops being sent on 1 March 2027.

        **Nothing deduplicates this for you.** Each call is one real POST, so a receiver that gets both the original and the redelivery sees the event twice. Both bodies carry `job.id`: treat a `bulk_address_job.finished` for a job id you have already handled as a repeat and drop it.

        **The delivery is queued, and the answer is `202`.** One attempt is a 10-second timeout retried twice, about half a minute against a receiver that is down — which is the receiver you are asking about. So this call does not wait for it: it answers `202` at once with the run, and `webhook_status` on it reads `pending`, a delivery asked for and not yet answered. Poll the run (`GET /api/v1/address/bulk/{jobId}`) until `webhook_status` is `delivered` or `failed`; `webhook_detail` then carries the HTTP status your endpoint gave, or the transport failure when it gave none. A `pending` run has one delivery in flight and asking again queues a second — wait for the first before you decide it failed.

        A run created without a `webhook_url` answers `409 WEBHOOK_NOT_CONFIGURED`, which is checked FIRST because waiting will never fix it; one still queued or running answers `409 JOB_NOT_FINISHED`, because there is no completion to announce yet, and so does a run that stops being finished between the check and the queueing (a resume in another window). A run whose last delivery *succeeded* is not refused — a receiver can lose a message it has already acknowledged, and only you know whether it did.

        A URL that resolves onto a private address is refused at delivery here exactly as on the first attempt, every time, because DNS can be repointed after a URL is saved.
      x-spaw-auth: bearer
      x-spaw-billing: "Free. A redelivery spends no credits and bills nothing. It is capped at 10 a minute counted per signed-in user — neither per key nor per account, so two keys held by one person share the one budget while two teammates have one each, and the dashboard button and the other products' redeliveries all spend the caller's own."
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - WEBHOOK_NOT_CONFIGURED
        - JOB_NOT_FINISHED
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
          example: 918
          description: 'The id returned at creation.'
      responses:
        202:
          description: 'The delivery is queued; `webhook_status` reads `pending` until a worker has an answer.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        $ref: '#/components/schemas/AddressBulkJob'
              example:
                success: true
                data:
                  job:
                    id: 918
                    status: completed
                    total: 20000
                    duplicate_count: 12
                    processed: 20000
                    valid: 18200
                    invalid: 1800
                    low: 16400
                    medium: 3200
                    high: 400
                    credits_used: 18200
                    stopped_reason: null
                    cancel_requested: false
                    country: null
                    deliverability: false
                    webhook_status: pending
                    webhook_detail: null
                    created_at: '2026-09-05T10:12:44+00:00'
                    finished_at: '2026-09-05T11:02:19+00:00'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        409:
          description: 'The run has no webhook URL (`WEBHOOK_NOT_CONFIGURED`), or it has not finished yet (`JOB_NOT_FINISHED`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: WEBHOOK_NOT_CONFIGURED
                  message: 'This run was created without a webhook URL, so there is no completion webhook to send.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          description: 'More than ten redeliveries in a minute from this user, across every product and the dashboard button alike. The shared limit named on other endpoints is not the one that fires here.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: RATE_LIMITED
                  message: 'Too many requests. Retry after the limit resets.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/address/bulk/918/webhook/redeliver \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/bulk/918/webhook/redeliver', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/address/bulk/918/webhook/redeliver',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/bulk/918/webhook/redeliver');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.redeliver_address_bulk_webhook(918)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.redeliverAddressBulkWebhook(918);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->redeliverAddressBulkWebhook(918);
  '/api/v1/address/bulk/{jobId}/results':
    get:
      operationId: download-address-bulk-results
      tags:
        - Address
      summary: "Download a bulk address job's results"
      description: |
        The result file as CSV: one row per input address, in input order, with the address as submitted, every field of the single-address response as a column, the datasets behind the answer, the credits the row cost, and whether the account's suppression list answered it (`suppressed`). A run created from an uploaded file carries that file's own columns first. Sources are joined by `;` as `dataset@version`, risk signals as `signal:weight`, the confirmation map as `part:source` and the changes by their names; `formatted` writes its lines on one line, joined by `, `. Fields containing spaces or commas are quoted. Add `?variant=valid|invalid|low|medium|high` to download only the rows that stand as written or do not, or one risk level; the default `full` returns everything. Available once the job is `completed`, or `cancelled` or `failed` with at least one processed row — those rows were billed, so they stay downloadable. A job still running answers `409 RESULTS_NOT_READY`, a cancelled job with nothing processed `409 JOB_CANCELLED`, and a failed job with nothing processed `409 JOB_FAILED`.

        `format=json` answers the same rows, filtered the same way, as objects keyed by the result file's own header instead of a CSV attachment: `{ "success": true, "data": { "job": …, "results": [ … ] }, "meta": { "variant", "request_id" } }`. It is streamed row by row exactly as the CSV is, so a 100,000-row run costs no more to answer either way. A CSV cell carries no type, so only the two the writer encodes come back typed: an empty cell is `null` and `true`/`false` are booleans. Everything else is the string as written — a number is never silently made numeric, because postcodes, phone numbers and house numbers lose their leading zeros that way — and the columns of an uploaded file come back exactly as they were uploaded.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - VALIDATION_FAILED
        - RESULTS_NOT_READY
        - JOB_CANCELLED
        - JOB_FAILED
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
        -
          name: variant
          in: query
          required: false
          schema:
            type: string
            enum:
              - full
              - valid
              - invalid
              - low
              - medium
              - high
          description: 'Restrict the file to the rows that stand as written or do not, or to one risk level. Defaults to full.'
        -
          name: format
          in: query
          required: false
          schema:
            type: string
            enum:
              - csv
              - json
            default: csv
          example: json
          description: '`csv` streams the file as an attachment; `json` streams the same rows as objects.'
      responses:
        200:
          description: 'The result rows, as a CSV attachment or as streamed JSON.'
          content:
            text/csv:
              schema:
                type: string
              example: |
                address,valid,reason,country,country_name,continent,is_eu,is_eea,privacy_regime,formatted,formatted_lines,formatted_local,organization,building,street,house_number,unit,po_box,dependent_locality,locality,administrative_area,postal_code,postal_code_formatted,postal_code_valid,postal_code_type,postcode_status,locality_matches_postcode,administrative_area_matches_postcode,address_type,address_type_source,is_po_box,is_private_mailbox,exists,match_level,register,register_coverage,premise_key,building_use,is_multi_unit,unit_check,street_suggestion,latitude,longitude,location_source,companies_registered,is_mail_drop,mail_drop_provider,mail_drop_source,deliverability_checked,is_deliverable,is_cmra,is_vacant,is_residential,confirmation,changes,looks_placeholder,risk_score,risk_level,risk_signals,ok_to_ship,blocked_by,sources,credits_used,suppressed
                "1600 Amphitheatre Parkway, Mountain View, CA 94043",true,,US,United States,NA,false,false,ccpa,"1600 Amphitheatre Parkway, MOUNTAIN VIEW, CA 94043","1600 Amphitheatre Parkway;MOUNTAIN VIEW, CA 94043",,,,Amphitheatre Parkway,1600,,,,Mountain View,CA,94043,94043,true,zip,,,,street,address_rules,false,,,,,,239f04782171457b,,,,,,,,,,,,false,,,,,postal_code:pattern;street:;house_number:;locality:;administrative_area:,,false,0,low,,true,,address-formats@2026-09-16;address-rules@2026-09-16,1,false
            application/json:
              schema:
                $ref: '#/components/schemas/BulkResultRows'
              example:
                success: true
                data:
                  job:
                    id: 17
                    status: completed
                  results:
                    -
                      address: '1600 Amphitheatre Parkway, Mountain View, CA 94043'
                      valid: true
                      reason: null
                      country: US
                      locality: 'Mountain View'
                      postal_code: '94043'
                      premise_key: 239f04782171457b
                      confirmation: 'postal_code:pattern;street:;house_number:;locality:;administrative_area:'
                      changes: null
                      risk_score: '0'
                      risk_level: low
                      credits_used: '1'
                      suppressed: false
                meta:
                  variant: full
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        409:
          description: 'The job is still running (`RESULTS_NOT_READY`), or was cancelled or failed before any row was processed (`JOB_CANCELLED`, `JOB_FAILED`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: RESULTS_NOT_READY
                  message: 'The results are not ready yet.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/address/bulk/{jobId}/results \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/bulk/{jobId}/results', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/address/bulk/{jobId}/results',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/bulk/{jobId}/results');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.download_address_bulk_results(job_id)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.downloadAddressBulkResults(jobId);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->downloadAddressBulkResults($jobId);
  /api/v1/address/public:
    post:
      operationId: validate-address-public
      tags:
        - Address
      summary: 'Validate an address from the browser with a publishable key'
      description: |
        The browser counterpart of `POST /api/v1/address`, for checking an address field in a checkout or signup form on the client without exposing a secret key. It is authenticated by a publishable `pk_` key in the body plus the browser's `Origin` header, which must match one of the domains the key is locked to. A missing `Origin` is rejected on purpose: servers use a secret key.

        The body takes the same address fields as the secret-key endpoint, including the required `country`. The lookup bills the key's owner under the normal rules (a fresh address that stands as written costs one credit; addresses that cannot, and 7-day repeats, are free) and answers `{ "success", "data" }` with **no meta block**, so page visitors never see the owner's balance. The licensed deliverability check is never offered here: a `deliverability` field in the body is ignored.

        Because the key sits in page source, give it a daily credit cap in the dashboard: once the cap is spent the endpoint answers `429 KEY_SPEND_CAP_REACHED` until the next day. Free answers never count toward the cap. A key that carries a Cloudflare Turnstile pair requires a confirmed `turnstile_token` and answers `403 TURNSTILE_FAILED` without one, before any credit is spent.

        Throttled at 20 requests per minute per IP.
      security: []
      x-spaw-auth: publishable-key
      x-spaw-billing: "Bills the key owner like a single lookup; capped by the key's daily credit cap when one is set."
      x-spaw-errors:
        - INVALID_PUBLISHABLE_KEY
        - ORIGIN_NOT_ALLOWED
        - TURNSTILE_FAILED
        - KEY_SPEND_CAP_REACHED
        - VALIDATION_FAILED
        - INSUFFICIENT_CREDITS
        - RATE_LIMITED
      parameters:
        -
          name: Origin
          in: header
          required: true
          schema:
            type: string
          example: 'https://www.example.com'
          description: "Sent by browsers automatically. Its host must be on the key's allowed-domain list."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - key
                - country
              properties:
                key:
                  type: string
                  maxLength: 64
                  description: 'A publishable key, which starts with `pk_`.'
                address:
                  type:
                    - string
                    - 'null'
                  maxLength: 500
                  description: 'The whole address written as it would be on an envelope. Required unless `address_line1` is sent.'
                address_line1:
                  type:
                    - string
                    - 'null'
                  maxLength: 255
                  description: 'The street line. Required unless `address` is sent.'
                address_line2:
                  type:
                    - string
                    - 'null'
                  maxLength: 255
                organization:
                  type:
                    - string
                    - 'null'
                  maxLength: 200
                building:
                  type:
                    - string
                    - 'null'
                  maxLength: 100
                dependent_locality:
                  type:
                    - string
                    - 'null'
                  maxLength: 100
                locality:
                  type:
                    - string
                    - 'null'
                  maxLength: 100
                administrative_area:
                  type:
                    - string
                    - 'null'
                  maxLength: 100
                postal_code:
                  type:
                    - string
                    - 'null'
                  maxLength: 32
                po_box:
                  type:
                    - string
                    - 'null'
                  maxLength: 64
                country:
                  type: string
                  maxLength: 2
                  description: 'An ISO 3166-1 alpha-2 country code, usually from the country select on the same form.'
                turnstile_token:
                  type:
                    - string
                    - 'null'
                  maxLength: 2048
                  description: 'Required when the key carries a Turnstile pair. A single-use token from the Turnstile widget on the page.'
                fields:
                  type:
                    - string
                    - 'null'
                  maxLength: 1000
                  description: "A comma-separated list of the answer's keys to keep, applied after billing; unknown names are ignored."
            example:
              key: pk_live_…
              address_line1: '221B Baker Street'
              locality: London
              postal_code: 'NW1 6XE'
              country: GB
      responses:
        200:
          description: 'The same fields as `POST /api/v1/address`, without a meta block.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/AddressResult'
              example:
                success: true
                data:
                  valid: true
                  reason: null
                  country: GB
                  street: 'Baker Street'
                  house_number: 221B
                  locality: London
                  postal_code: 'NW1 6XE'
                  postal_code_valid: true
                  postal_code_type: postal
                  address_type: street
                  is_po_box: false
                  deliverability_checked: false
                  risk_score: 0
                  risk_level: low
        401:
          description: 'The publishable key does not exist or was revoked.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: INVALID_PUBLISHABLE_KEY
                  message: 'That publishable key does not exist or was revoked.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        402:
          $ref: '#/components/responses/InsufficientCredits'
        403:
          description: "The page's origin is not on the key's allowed-domain list (`ORIGIN_NOT_ALLOWED`), or the key requires a Turnstile token that was missing or not confirmed (`TURNSTILE_FAILED`)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: ORIGIN_NOT_ALLOWED
                  message: 'This publishable key cannot be used from this origin.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          description: "The key has spent its owner's daily credit cap (`KEY_SPEND_CAP_REACHED`), or the IP exceeded 20 requests per minute (`RATE_LIMITED`)."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: KEY_SPEND_CAP_REACHED
                  message: 'This publishable key has reached its daily credit cap. The counter resets each day.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/address/public \
              -H "Origin: https://www.example.com" \
              -H "Content-Type: application/json" \
              -d '{
              "key": "pk_live_…",
              "address_line1": "221B Baker Street",
              "locality": "London",
              "postal_code": "NW1 6XE",
              "country": "GB"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/public', {
              method: 'POST',
              headers: {
                'Origin': 'https://www.example.com',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "key": "pk_live_…",
                "address_line1": "221B Baker Street",
                "locality": "London",
                "postal_code": "NW1 6XE",
                "country": "GB"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/address/public',
                headers={'Origin': 'https://www.example.com'},
                json={
                    'key': 'pk_live_…',
                    'address_line1': '221B Baker Street',
                    'locality': 'London',
                    'postal_code': 'NW1 6XE',
                    'country': 'GB'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/public');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Origin: https://www.example.com', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'key' => 'pk_live_…',
                    'address_line1' => '221B Baker Street',
                    'locality' => 'London',
                    'postal_code' => 'NW1 6XE',
                    'country' => 'GB'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
  '/api/v1/address/postcode/{postcode}':
    get:
      operationId: find-addresses-by-postcode
      tags:
        - Address
      summary: 'List the addresses at a postcode'
      description: |
        Answers the addresses at one postcode, for callers that want a "find my address" picker under a postcode field.

        Two sources answer. A licensed partner, where one is configured, lists every delivery point for the countries it is licensed for and is asked first — a full British list derives from a postal operator's own address file, and serving it needs a licence ([can you look up UK addresses from a postcode for free?](https://spaw.co/guides/a-uk-postcode-is-not-an-address)). Otherwise, wherever an open national register's street listing is installed (Switzerland, Austria, Czechia, Norway, Belgium, Luxembourg, Latvia, Australia and the Netherlands in full, Denmark as its register stood on 26 September 2026, the parts of the United States, France and Brazil their registers carry, Berlin, whose register is filed by postcode, and Great Britain's streets by postcode district from Ordnance Survey's Open Names), the answer is the streets that register lists inside the postcode, in its own spelling, sorted and with no house numbers: `street`, `locality`, `postal_code` and `country` are filled, `house_number`, `unit` and `identifier` stay null. In the United States and its territories a ZIP+4 lists the streets of its five-digit ZIP, and each address's `postal_code` is that ZIP. An empty list is answered only where the registers' coverage is complete and none of them holds an address at the postcode. A country with neither a licensed list nor an open street listing answers `501 ADDRESS_LIST_UNAVAILABLE` and charges nothing, rather than returning an empty list that would read as "there are no addresses here" — and so does a postcode the installed listing cannot speak for, with a message saying which: a code outside the register's area (Northern Irish BT, forces BFPO and BF, and non-geographic BX codes are outside Ordnance Survey's Great Britain data), a postcode in a part of the country no installed register covers (Germany outside Berlin, or a Belgian region whose register is not installed), a postcode the installed register cannot vouch for (a British district Open Names files no street under, since it files each road under one district), and a postcode where a register whose coverage is complete holds addresses but none on a named street (many Czech villages number their houses without one). Nothing else in the address API depends on it.

        `country` is an ISO 3166-1 alpha-2 code and defaults to `GB`. The postcode is passed in the path, with or without its space (`NW1 6XE` and `NW16XE` are the same postcode). At most 100 addresses are returned for one postcode; `count` is how many the answer carries.
      x-spaw-auth: bearer
      x-spaw-billing: 'From the licensed partner: 25 credits per answered list, priced at twice or more what the partner charges for it, and nothing for a partner that could not answer. From an open register: 1 credit when the listing holds streets at the postcode, free for the next seven days for the same postcode, and free for an empty list. A country neither covers, and a postcode the listing cannot speak for, answer 501 and cost nothing.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - INSUFFICIENT_CREDITS
        - ADDRESS_LIST_UNAVAILABLE
        - RATE_LIMITED
      parameters:
        -
          name: postcode
          in: path
          required: true
          schema:
            type: string
          example: 'NW1 6XE'
          description: "The postcode to list, in the country's own notation."
        -
          name: country
          in: query
          required: false
          schema:
            type: string
            default: GB
          example: GB
          description: 'An ISO 3166-1 alpha-2 code the postal format table covers.'
      responses:
        200:
          description: "The addresses the partner lists at that postcode, or the streets an open register lists there — an empty list only where the registers' coverage is complete and none of them holds an address at the postcode."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AddressListResponse'
              example:
                success: true
                data:
                  postcode: 'NW1 6XE'
                  country: GB
                  available: true
                  addresses:
                    -
                      address_line1: '221B Baker Street'
                      address_line2: null
                      organization: null
                      street: 'Baker Street'
                      house_number: 221B
                      unit: null
                      locality: London
                      administrative_area: null
                      postal_code: 'NW1 6XE'
                      country: GB
                      identifier: '10023456789'
                  count: 1
                meta:
                  credits_used: 2
                  credits_remaining: 8
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
        501:
          description: "Neither a licensed address-list partner nor an open register's street listing covers the country — here Italy, which has neither — or the installed listing cannot speak for the postcode: a code outside the register's area, a postcode in a part of the country no installed register covers, a postcode the installed register cannot vouch for, or one whose addresses are on no named street. The message says which (`The DE street listing does not cover 50667: …` for a Cologne postcode, since the only German register filed by postcode is Berlin's). Nothing was charged."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: ADDRESS_LIST_UNAVAILABLE
                  message: 'Listing the addresses at a postcode needs a licensed address-list partner or an open register with a street listing, and neither is configured for IT.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/address/postcode/NW1 6XE \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/postcode/NW1 6XE', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/address/postcode/NW1 6XE',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/postcode/NW1 6XE');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.addresses_at_postcode('NW1 6XE')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.addressesAtPostcode('NW1 6XE');
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->addressesAtPostcode('NW1 6XE');
  /api/v1/address/streets:
    get:
      operationId: list-address-streets
      tags:
        - Address
      summary: 'List the streets at a postcode'
      description: |
        Answers every street a national address register lists inside one postcode, in the register's own spelling and sorted: the list behind a street picker under a postcode field, and the list a single lookup's `street_suggestion` is drawn from. One call returns the whole postcode, so a form filters locally as the visitor types rather than calling per keystroke.

        Open data only: the registers publish their streets and Spaw lists them back, with no house numbers and nothing about who is there. Available wherever a register with a street listing is installed — Switzerland, Austria, Czechia, Norway, Belgium, Luxembourg, Latvia, Australia and the Netherlands in full, Denmark as its register stood on 26 September 2026, the parts of the United States, France and Brazil their registers carry, Berlin, whose register is filed by postcode (the North Rhine-Westphalia and Rhineland-Palatinate registers are filed by town, so they list no street at a postcode), and Great Britain by postcode district from Ordnance Survey's Open Names. Where several registers share a country region by region, as Belgium's three do, the postcode is read from the register that lists streets there, and `register` names it. A country with no such register, a postcode no register lists a street at, and a postcode the listing does not cover all answer `404 ADDRESS_STREETS_NOT_FOUND` and cost nothing, and the message says which. `No street is listed at 9999 in CH.` is the answer only where the registers' coverage is complete and none of them holds an address at the postcode; a code outside the register's area (Northern Irish BT, forces BFPO and BF, and non-geographic BX codes are outside Ordnance Survey's Great Britain data), a postcode in a part of the country no installed register covers (Germany outside Berlin), a postcode the installed register cannot vouch for (a British district Open Names files no street under) and one where a register whose coverage is complete holds addresses but none on a named street (many Czech villages) each say so, rather than reading as a postcode with no street.

        `country` is an ISO 3166-1 alpha-2 code the postal format table covers and `postal_code` the postcode in the country's own notation, with or without its space. In Great Britain the outward code alone (`N1`, `SW1A`) is accepted too, because the listing is filed by postcode district. In the United States and its territories a ZIP+4 answers every street in its five-digit ZIP, because no register files streets by the four-digit add-on; the answer names the ZIP, and every spelling of one ZIP shares the seven-day repeat marker.
      x-spaw-auth: bearer
      x-spaw-billing: '1 credit per answered postcode, free for the next seven days for the same postcode; in the United States and its territories every ZIP+4 in one ZIP counts as that ZIP. A country or postcode with nothing to list, and a postcode the listing does not cover, cost nothing.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - INSUFFICIENT_CREDITS
        - ADDRESS_STREETS_NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: country
          in: query
          required: true
          schema:
            type: string
            minLength: 2
            maxLength: 2
          example: CH
          description: 'An ISO 3166-1 alpha-2 code the postal format table covers, in either case.'
        -
          name: postal_code
          in: query
          required: true
          schema:
            type: string
            maxLength: 32
          example: '8001'
          description: "The postcode to list, in the country's own notation."
      responses:
        200:
          description: 'The streets the register lists at that postcode.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      country:
                        type: string
                        description: 'ISO 3166-1 alpha-2.'
                      postal_code:
                        type: string
                        description: 'The postcode as it was asked for, trimmed — except that a ZIP+4 answers the five-digit ZIP whose streets the list holds (`94043` for `94043-1351`).'
                      register:
                        type: string
                        description: "The register that answered, under the same dataset name it carries in a lookup's `sources` (ch-gebaeudeadressverzeichnis, …)."
                      streets:
                        type: array
                        description: 'Every street the register lists inside the postcode, in its own spelling, sorted.'
                        items:
                          type: string
                      count:
                        type: integer
                        description: 'How many streets the answer carries.'
                  meta:
                    type: object
                    properties:
                      credits_used:
                        type: integer
                      credits_remaining:
                        type: integer
                      cache_hit:
                        type: boolean
                      request_id:
                        type:
                          - string
                          - 'null'
              example:
                success: true
                data:
                  country: CH
                  postal_code: '8001'
                  register: ch-gebaeudeadressverzeichnis
                  streets:
                    - Bahnhofstrasse
                    - Limmatquai
                    - Münsterhof
                    - Paradeplatz
                    - Rennweg
                  count: 5
                meta:
                  credits_used: 1
                  credits_remaining: 9
                  cache_hit: false
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        404:
          description: 'No register with a street listing covers the country, the registers list no street at the postcode, or the listing does not cover the postcode; the message says which. Nothing was charged.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: ADDRESS_STREETS_NOT_FOUND
                  message: 'No street is listed at 9999 in CH.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/address/streets \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/streets', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/address/streets',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/streets');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.address_streets('CH', '8001')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.addressStreets('CH', '8001');
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->addressStreets('CH', '8001');
  /api/v1/address/policy:
    get:
      operationId: get-address-policy
      tags:
        - Address
      summary: "Read the account's shipping policy"
      description: |
        The rules behind `ok_to_ship` on every address lookup this account runs: the signals that block, the risk-score ceiling, and whether a register must have confirmed the building. The policy belongs to the account, so every member reads and changes the same one. An address that cannot stand as written is always blocked whatever the policy says.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - RATE_LIMITED
      responses:
        200:
          description: 'The policy in force.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/ShippingPolicy'
              example:
                success: true
                data:
                  max_risk_score: 60
                  block_signals:
                    - undeliverable
                    - placeholder
                    - vacant
                  require_exists: false
                  is_default: true
                  updated_at: null
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/address/policy \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/policy', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/address/policy',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/policy');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.get_address_policy()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.getAddressPolicy();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->getAddressPolicy();
    put:
      operationId: update-address-policy
      tags:
        - Address
      summary: 'Change one or more rules of the shipping policy'
      description: |
        Names only the rules that change; the rest stay as they were, and the change applies to the account's next lookup. `max_risk_score: null` ignores the score, `block_signals: []` blocks on no signal, `require_exists: true` blocks every address a register did not confirm — which is every address in a country with no register installed, so switch it on only for the countries you ship to. A body naming none of the three rules is refused with a 422 keyed `policy`, because that is the shape a misspelled key takes.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - RATE_LIMITED
        - VALIDATION_FAILED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                max_risk_score:
                  type:
                    - integer
                    - 'null'
                  minimum: 0
                  maximum: 100
                  description: 'Block when risk_score is above this; null ignores the score.'
                block_signals:
                  type:
                    - array
                    - 'null'
                  maxItems: 17
                  uniqueItems: true
                  items:
                    type: string
                    enum:
                      - undeliverable
                      - private_mailbox
                      - placeholder
                      - mail_drop
                      - reported_returned
                      - vacant
                      - general_delivery
                      - po_box
                      - unit_unexpected
                      - terminated_postcode
                      - locality_mismatch
                      - region_mismatch
                      - not_in_register
                      - unit_missing
                      - street_not_found
                      - number_out_of_range
                      - bulk_mail
                  description: 'Signals that block whenever they fire, whatever the score.'
                require_exists:
                  type: boolean
                  description: 'Block unless a register confirmed the building (exists true).'
            example:
              max_risk_score: 40
              block_signals:
                - undeliverable
                - placeholder
                - vacant
                - mail_drop
      responses:
        200:
          description: 'The policy now in force.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/ShippingPolicy'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X PUT https://spaw.co/api/v1/address/policy \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "max_risk_score": 40,
              "block_signals": [
                "undeliverable",
                "placeholder",
                "vacant",
                "mail_drop"
              ]
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/policy', {
              method: 'PUT',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "max_risk_score": 40,
                "block_signals": [
                  "undeliverable",
                  "placeholder",
                  "vacant",
                  "mail_drop"
                ]
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.put(
                'https://spaw.co/api/v1/address/policy',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'max_risk_score': 40,
                    'block_signals': [
                        'undeliverable',
                        'placeholder',
                        'vacant',
                        'mail_drop'
                    ]
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/policy');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'PUT',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'max_risk_score' => 40,
                    'block_signals' => [
                        'undeliverable',
                        'placeholder',
                        'vacant',
                        'mail_drop'
                    ]
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.update_address_policy(max_risk_score=40, block_signals=[
                'undeliverable',
                'placeholder',
                'vacant',
                'mail_drop'
            ])
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.updateAddressPolicy({
                maxRiskScore: 40,
                blockSignals: [
                    'undeliverable',
                    'placeholder',
                    'vacant',
                    'mail_drop'
                ]
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->updateAddressPolicy([
                'max_risk_score' => 40,
                'block_signals' => [
                    'undeliverable',
                    'placeholder',
                    'vacant',
                    'mail_drop'
                ]
            ]);
    delete:
      operationId: reset-address-policy
      tags:
        - Address
      summary: 'Restore the default shipping policy'
      description: 'Puts the published defaults back — block above 60 and on undeliverable, placeholder and vacant, no register requirement — and forgets when the policy was last changed.'
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - RATE_LIMITED
      responses:
        200:
          description: 'The defaults, in force again.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/ShippingPolicy'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X DELETE https://spaw.co/api/v1/address/policy \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/policy', {
              method: 'DELETE',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.delete(
                'https://spaw.co/api/v1/address/policy',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/policy');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'DELETE',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.reset_address_policy()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.resetAddressPolicy();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->resetAddressPolicy();
  /api/v1/address/feedback:
    post:
      operationId: report-address-feedback
      tags:
        - Address
      summary: 'Report what happened to post you sent'
      description: |
        Tells Spaw what became of post your account sent to an address: `delivered`, `returned` (it came back), `moved` (the occupant is no longer there) or `refused` (delivery was refused). Your own reports feed your own later lookups of that address, with the dataset `address-feedback`: an address whose most recent report within 90 days is `returned`, `moved` or `refused` rather than `delivered` scores `reported_returned` (+50), and a delivery reported afterwards cancels it.

        Reports also keep your postal suppression list: a `moved` or `refused` report suppresses the address at once, a second `returned` report with no later delivery suppresses it, and a `delivered` report clears an entry your reports added.

        There is deliberately no cross-account reputation here and no provider webhook. A postal address is where somebody lives, and a shared "post fails here" database would be a database about residences. What you report raises the risk of your own lookups and of nobody else's, and no other account ever sees it.

        Each item is a written address or the separated fields, and may name its own `country`, which wins over the request-level one. Addresses are folded the way a lookup folds them, so any spelling of one building lines up; an item that names no building — no postcode or locality, no street, no house number — is skipped and counted, not rejected. Feedback is free, never billed and never logged as a lookup. Reports are kept for 180 days.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - items
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 1000
                  items:
                    $ref: '#/components/schemas/AddressFeedbackItem'
                  description: '1 to 1,000 reports per request.'
                country:
                  type:
                    - string
                    - 'null'
                  maxLength: 2
                  description: 'An ISO 3166-1 alpha-2 code applied to items that do not name their own.'
            example:
              country: GB
              items:
                -
                  address: '221B Baker Street, London NW1 6XE'
                  outcome: returned
                -
                  address_line1: '10 Downing Street'
                  locality: London
                  postal_code: 'SW1A 2AA'
                  outcome: moved
                  occurred_at: '2026-09-05T09:00:00Z'
      responses:
        202:
          description: 'The reports were recorded.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      recorded:
                        type: integer
                      skipped:
                        type: integer
                        description: 'Items that named no building to file a report against.'
              example:
                success: true
                data:
                  recorded: 2
                  skipped: 0
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/address/feedback \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "country": "GB",
              "items": [
                {
                  "address": "221B Baker Street, London NW1 6XE",
                  "outcome": "returned"
                },
                {
                  "address_line1": "10 Downing Street",
                  "locality": "London",
                  "postal_code": "SW1A 2AA",
                  "outcome": "moved",
                  "occurred_at": "2026-09-05T09:00:00Z"
                }
              ]
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/feedback', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "country": "GB",
                "items": [
                  {
                    "address": "221B Baker Street, London NW1 6XE",
                    "outcome": "returned"
                  },
                  {
                    "address_line1": "10 Downing Street",
                    "locality": "London",
                    "postal_code": "SW1A 2AA",
                    "outcome": "moved",
                    "occurred_at": "2026-09-05T09:00:00Z"
                  }
                ]
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/address/feedback',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'country': 'GB',
                    'items': [
                        {
                            'address': '221B Baker Street, London NW1 6XE',
                            'outcome': 'returned'
                        },
                        {
                            'address_line1': '10 Downing Street',
                            'locality': 'London',
                            'postal_code': 'SW1A 2AA',
                            'outcome': 'moved',
                            'occurred_at': '2026-09-05T09:00:00Z'
                        }
                    ]
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/feedback');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'country' => 'GB',
                    'items' => [
                        [
                            'address' => '221B Baker Street, London NW1 6XE',
                            'outcome' => 'returned'
                        ],
                        [
                            'address_line1' => '10 Downing Street',
                            'locality' => 'London',
                            'postal_code' => 'SW1A 2AA',
                            'outcome' => 'moved',
                            'occurred_at' => '2026-09-05T09:00:00Z'
                        ]
                    ]
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.report_address_feedback([
                {
                    'address': '221B Baker Street, London NW1 6XE',
                    'outcome': 'returned'
                },
                {
                    'address_line1': '10 Downing Street',
                    'locality': 'London',
                    'postal_code': 'SW1A 2AA',
                    'outcome': 'moved',
                    'occurred_at': '2026-09-05T09:00:00Z'
                }
            ], country='GB')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.reportAddressFeedback([
                {
                    address: '221B Baker Street, London NW1 6XE',
                    outcome: 'returned'
                },
                {
                    address_line1: '10 Downing Street',
                    locality: 'London',
                    postal_code: 'SW1A 2AA',
                    outcome: 'moved',
                    occurred_at: '2026-09-05T09:00:00Z'
                }
            ], {
                country: 'GB'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->reportAddressFeedback([
                [
                    'address' => '221B Baker Street, London NW1 6XE',
                    'outcome' => 'returned'
                ],
                [
                    'address_line1' => '10 Downing Street',
                    'locality' => 'London',
                    'postal_code' => 'SW1A 2AA',
                    'outcome' => 'moved',
                    'occurred_at' => '2026-09-05T09:00:00Z'
                ]
            ], country: 'GB');
  /api/v1/address/feedback/summary:
    get:
      operationId: address-feedback-summary
      tags:
        - Address
      summary: 'Reported postal outcomes'
      description: |
        The last 90 days of outcomes your account reported: the total, how many distinct addresses they cover, and the count per outcome. Your own rows only.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - RATE_LIMITED
      responses:
        200:
          description: 'The summary.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/AddressFeedbackSummary'
              example:
                success: true
                data:
                  window_days: 90
                  total: 1840
                  addresses: 1712
                  outcomes:
                    delivered: 1750
                    returned: 61
                    moved: 22
                    refused: 7
        401:
          $ref: '#/components/responses/Unauthenticated'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/address/feedback/summary \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/feedback/summary', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/address/feedback/summary',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/feedback/summary');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.address_feedback_summary()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.addressFeedbackSummary();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->addressFeedbackSummary();
  /api/v1/address/suppressions:
    get:
      operationId: list-address-suppressions
      tags:
        - Address
      summary: 'List the address suppression list'
      description: |
        The account's postal suppression list, newest first, 100 entries per page. Your own delivery reports add an address with source `feedback`: `moved` or `refused` at once, `returned` on the second report with no later delivery. Imports arrive with source `imported`. Batch, bulk and monitor runs answer a suppressed address free, with `valid` false, reason `suppressed` and `meta.suppressed` true; a single lookup always runs fresh. An import never goes stale. An entry your reports added is re-checked rather than trusted once it is untouched for 90 days: the lookup keeps it while the reports still stand behind it, and drops it once they do not; a new moved, refused or returned report renews it.

        The list is per account and is never read for anybody else.

        Every filter is optional and they combine. `value` looks one address up: it is folded against its `country`'s postal format the way the list stores it, so `10 downing st, london sw1a2aa` finds the entry filed as `10 Downing Street, London, SW1A 2AA`. `country` is required whenever `value` is sent — an address folds to a building against one country's format, and without it the search would answer "no such entry" for a row you are looking at. An address that names no building matches nothing. An unparseable date or an out-of-range `per_page` is a `422`, not a silently unfiltered page.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      parameters:
        -
          name: value
          in: query
          required: false
          schema:
            type: string
            maxLength: 500
          example: '10 downing st, london sw1a2aa'
          description: 'One written address, in any spelling of it. Folded before it is matched; requires `country`.'
        -
          name: country
          in: query
          required: false
          schema:
            type: string
            minLength: 2
            maxLength: 2
          example: GB
          description: 'The ISO 3166-1 alpha-2 country `value` is read against. Required with `value`.'
        -
          name: source
          in: query
          required: false
          schema:
            type: string
            enum:
              - auto
              - imported
              - feedback
          example: imported
          description: 'Only entries this source added.'
        -
          name: reason
          in: query
          required: false
          schema:
            type: string
            enum:
              - returned
              - moved
              - refused
              - imported
          example: moved
          description: 'Only entries carrying this reason.'
        -
          name: since
          in: query
          required: false
          schema:
            type: string
            format: date
          example: '2026-08-01'
          description: 'Only entries added on or after this date.'
        -
          name: until
          in: query
          required: false
          schema:
            type: string
            format: date
          example: '2026-09-01'
          description: 'Only entries added on or before this date. A bare date means the whole of that day.'
        -
          name: page
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            default: 1
          example: 1
          description: 'The page to return.'
        -
          name: per_page
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
          example: 100
          description: 'Entries per page, up to 500.'
      responses:
        200:
          description: 'One page of entries.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      suppressions:
                        type: array
                        items:
                          $ref: '#/components/schemas/AddressSuppression'
                  meta:
                    type: object
                    properties:
                      page:
                        type: integer
                      per_page:
                        type: integer
                      total:
                        type: integer
              example:
                success: true
                data:
                  suppressions:
                    -
                      id: 412
                      address: '221B Baker Street, London NW1 6XE'
                      reason: returned
                      source: feedback
                      added_at: '2026-09-05T08:30:00+00:00'
                    -
                      id: 411
                      address: '10 Downing Street, London, SW1A 2AA'
                      reason: imported
                      source: imported
                      added_at: '2026-09-04T17:02:11+00:00'
                meta:
                  page: 1
                  per_page: 100
                  total: 2
        401:
          $ref: '#/components/responses/Unauthenticated'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/address/suppressions \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/suppressions', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/address/suppressions',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/suppressions');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.list_address_suppressions()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.listAddressSuppressions();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->listAddressSuppressions();
    post:
      operationId: add-address-suppressions
      tags:
        - Address
      summary: 'Import known-dead addresses'
      description: |
        Adds up to 10,000 addresses per request to the postal suppression list — a mailing house's return file, a hand-kept list. Each item is a written address or an object of the same fields the single lookup takes, and may name its own `country`; `country` at the top level applies to the rest. Addresses are folded the way a lookup folds them; ones that name no building (no postcode or locality, no street, no house number) are counted as `invalid`. An address already on the list is counted as `already_suppressed`: it keeps its label and reason, and becomes an import too, so no later report or delivery clears it — it leaves only when you remove it. An import never goes stale, so importing an address again is never needed. Imported entries are honoured from day one at no credit cost: batch, bulk and monitor runs answer them with reason `suppressed`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - items
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 10000
                  items:
                    type:
                      - string
                      - object
                    maxLength: 500
                    properties:
                      address:
                        type:
                          - string
                          - 'null'
                        maxLength: 500
                        description: 'The whole address written as it would be on an envelope.'
                      address_line1:
                        type:
                          - string
                          - 'null'
                        maxLength: 255
                        description: 'The street line, when the address arrives separated.'
                      address_line2:
                        type:
                          - string
                          - 'null'
                        maxLength: 255
                      organization:
                        type:
                          - string
                          - 'null'
                        maxLength: 200
                      building:
                        type:
                          - string
                          - 'null'
                        maxLength: 100
                      dependent_locality:
                        type:
                          - string
                          - 'null'
                        maxLength: 100
                      locality:
                        type:
                          - string
                          - 'null'
                        maxLength: 100
                      administrative_area:
                        type:
                          - string
                          - 'null'
                        maxLength: 100
                      postal_code:
                        type:
                          - string
                          - 'null'
                        maxLength: 32
                      po_box:
                        type:
                          - string
                          - 'null'
                        maxLength: 64
                      country:
                        type:
                          - string
                          - 'null'
                        maxLength: 2
                        description: "This item's own country. Wins over the list-level one."
                  description: '1 to 10,000 addresses. Each item is a written address as a string, or an object of the same fields the single lookup takes.'
                country:
                  type:
                    - string
                    - 'null'
                  maxLength: 2
                  description: 'An ISO 3166-1 alpha-2 code applied to every item that does not name its own.'
            example:
              country: GB
              items:
                - '221B Baker Street, London NW1 6XE'
                -
                  address_line1: '10 Downing Street'
                  locality: London
                  postal_code: 'SW1A 2AA'
      responses:
        200:
          description: 'How many entries were added, already present, or named no building.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      added:
                        type: integer
                      already_suppressed:
                        type: integer
                      invalid:
                        type: integer
              example:
                success: true
                data:
                  added: 2
                  already_suppressed: 0
                  invalid: 0
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/address/suppressions \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "country": "GB",
              "items": [
                "221B Baker Street, London NW1 6XE",
                {
                  "address_line1": "10 Downing Street",
                  "locality": "London",
                  "postal_code": "SW1A 2AA"
                }
              ]
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/suppressions', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "country": "GB",
                "items": [
                  "221B Baker Street, London NW1 6XE",
                  {
                    "address_line1": "10 Downing Street",
                    "locality": "London",
                    "postal_code": "SW1A 2AA"
                  }
                ]
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/address/suppressions',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'country': 'GB',
                    'items': [
                        '221B Baker Street, London NW1 6XE',
                        {
                            'address_line1': '10 Downing Street',
                            'locality': 'London',
                            'postal_code': 'SW1A 2AA'
                        }
                    ]
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/suppressions');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'country' => 'GB',
                    'items' => [
                        '221B Baker Street, London NW1 6XE',
                        [
                            'address_line1' => '10 Downing Street',
                            'locality' => 'London',
                            'postal_code' => 'SW1A 2AA'
                        ]
                    ]
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.add_address_suppressions([
                '221B Baker Street, London NW1 6XE',
                {
                    'address_line1': '10 Downing Street',
                    'locality': 'London',
                    'postal_code': 'SW1A 2AA'
                }
            ], country='GB')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.addAddressSuppressions([
                '221B Baker Street, London NW1 6XE',
                {
                    address_line1: '10 Downing Street',
                    locality: 'London',
                    postal_code: 'SW1A 2AA'
                }
            ], {
                country: 'GB'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->addAddressSuppressions([
                '221B Baker Street, London NW1 6XE',
                [
                    'address_line1' => '10 Downing Street',
                    'locality' => 'London',
                    'postal_code' => 'SW1A 2AA'
                ]
            ], country: 'GB');
    delete:
      operationId: remove-address-suppressions
      tags:
        - Address
      summary: 'Remove addresses from the address suppression list'
      description: |
        Removes up to 1,000 addresses per request by the address itself, for a caller that knows the address but not the id it was filed under. Each value is a written address or an object of the same fields the import takes, and is folded against `country` the way the list stores it — so `10 downing st, london sw1a2aa` removes the entry filed as `10 Downing Street, London, SW1A 2AA`. `country` is required: an address folds to a building against one country's format, and a value that folds to nothing would be reported as not on a list it is on. An item may still name its own country.

        The answer names every address it was given, written the way the list labels it: `removed` are the ones that were on the list and no longer are, `not_found` the ones that were not on it — including an address that names no building, and one that is on another account's list. Two spellings of one building both count as removed.

        The cap is 1,000 rather than the import's 10,000 because the answer names every value back: a request over the cap is a `422` and removes nothing. Removing an entry means the address is looked up fresh the next time a batch, bulk or monitor run meets it.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - values
                - country
              properties:
                values:
                  type: array
                  minItems: 1
                  maxItems: 1000
                  items:
                    type:
                      - string
                      - object
                    properties:
                      address:
                        type: string
                        maxLength: 500
                      address_line1:
                        type: string
                        maxLength: 255
                      address_line2:
                        type: string
                        maxLength: 255
                      organization:
                        type: string
                        maxLength: 200
                      building:
                        type: string
                        maxLength: 100
                      dependent_locality:
                        type: string
                        maxLength: 100
                      locality:
                        type: string
                        maxLength: 100
                      administrative_area:
                        type: string
                        maxLength: 100
                      postal_code:
                        type: string
                        maxLength: 32
                      po_box:
                        type: string
                        maxLength: 64
                      country:
                        type:
                          - string
                          - 'null'
                        description: 'An ISO 3166-1 alpha-2 code for this address.'
                  description: '1 to 1,000 addresses, each a written line or an object of the separated fields.'
                country:
                  type: string
                  description: 'The ISO 3166-1 alpha-2 country every value is read against.'
            example:
              values:
                - '10 downing st, london sw1a2aa'
                -
                  address_line1: '221B Baker Street'
                  postal_code: 'NW1 6XE'
              country: GB
      responses:
        200:
          description: 'Which of the addresses were on the list and which were not.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      removed:
                        type: array
                        items:
                          type: string
                        description: 'The addresses that were on the list, as the list labels them.'
                      not_found:
                        type: array
                        items:
                          type: string
                        description: 'The addresses that were not on the list, as the list would label them.'
              example:
                success: true
                data:
                  removed:
                    - '10 downing st, london sw1a2aa'
                  not_found:
                    - '221B Baker Street, NW1 6XE'
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X DELETE https://spaw.co/api/v1/address/suppressions \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "values": [
                "10 downing st, london sw1a2aa",
                {
                  "address_line1": "221B Baker Street",
                  "postal_code": "NW1 6XE"
                }
              ],
              "country": "GB"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/suppressions', {
              method: 'DELETE',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "values": [
                  "10 downing st, london sw1a2aa",
                  {
                    "address_line1": "221B Baker Street",
                    "postal_code": "NW1 6XE"
                  }
                ],
                "country": "GB"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.delete(
                'https://spaw.co/api/v1/address/suppressions',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'values': [
                        '10 downing st, london sw1a2aa',
                        {
                            'address_line1': '221B Baker Street',
                            'postal_code': 'NW1 6XE'
                        }
                    ],
                    'country': 'GB'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/suppressions');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'DELETE',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'values' => [
                        '10 downing st, london sw1a2aa',
                        [
                            'address_line1' => '221B Baker Street',
                            'postal_code' => 'NW1 6XE'
                        ]
                    ],
                    'country' => 'GB'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.remove_address_suppressions([
                '10 downing st, london sw1a2aa',
                {
                    'address_line1': '221B Baker Street',
                    'postal_code': 'NW1 6XE'
                }
            ], country='GB')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.removeAddressSuppressions([
                '10 downing st, london sw1a2aa',
                {
                    address_line1: '221B Baker Street',
                    postal_code: 'NW1 6XE'
                }
            ], {
                country: 'GB'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->removeAddressSuppressions([
                '10 downing st, london sw1a2aa',
                [
                    'address_line1' => '221B Baker Street',
                    'postal_code' => 'NW1 6XE'
                ]
            ], country: 'GB');
  '/api/v1/address/suppressions/{suppressionId}':
    delete:
      operationId: remove-address-suppression
      tags:
        - Address
      summary: 'Remove an address suppression entry'
      description: |
        Deletes one entry so the address is looked up fresh the next time a batch, bulk or monitor run meets it. An entry that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: suppressionId
          in: path
          required: true
          schema:
            type: integer
          example: 412
          description: "The entry's id from the list endpoint."
      responses:
        200:
          description: 'The entry was removed.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
              example:
                success: true
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X DELETE https://spaw.co/api/v1/address/suppressions/412 \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/suppressions/412', {
              method: 'DELETE',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.delete(
                'https://spaw.co/api/v1/address/suppressions/412',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/suppressions/412');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'DELETE',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.remove_address_suppression(412)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.removeAddressSuppression(412);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->removeAddressSuppression(412);
  /api/v1/address/monitors:
    get:
      operationId: list-address-monitors
      tags:
        - Address
      summary: "List the account's address monitors"
      description: |
        The saved address lists this account re-checks on a schedule, newest first, 100 per page. Each monitor carries the summary of its last run: how many addresses were `ok`, how many fired each of the five conditions, how many were `unverifiable` (valid, but no dataset installed for the country could say anything about the place), how many changed status, how many newly became flagged, the credits the run spent, and `stopped_reason` when it did not finish cleanly: `insufficient_credits` or `key_spend_cap` where the run stopped part-way, `run_failed` where it died outright, and `run_failed_retrying` where it died and the schedule has been pulled in to check again — `retry_at` in the same block says when that becomes due, and the hourly sweep picks it up at or after that, so between 45 minutes and about an hour and three quarters later. A summary carrying a `stopped_reason` of either failure kind has NO counts in it at all: the run produced none, so read a missing count as unknown rather than as zero. The addresses and their statuses are on the single-monitor endpoint.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - RATE_LIMITED
      parameters:
        -
          name: page
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            default: 1
          example: 1
          description: 'The page to return.'
      responses:
        200:
          description: 'One page of monitors.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitors:
                        type: array
                        items:
                          $ref: '#/components/schemas/AddressMonitor'
                  meta:
                    type: object
                    properties:
                      page:
                        type: integer
                      per_page:
                        type: integer
                      total:
                        type: integer
                      retention_days:
                        type: integer
                        description: 'How long a list nobody runs is kept before it is deleted.'
              example:
                success: true
                data:
                  monitors:
                    -
                      id: 9
                      name: 'Supplier offices'
                      cadence: monthly
                      address_count: 2
                      country: GB
                      deliverability: false
                      next_run_at: '2026-10-05T08:00:00+00:00'
                      last_run_at: '2026-09-05T08:00:04+00:00'
                      last_summary:
                        total: 2
                        ok: 1
                        invalid: 0
                        not_in_register: 1
                        terminated_postcode: 0
                        mail_drop: 0
                        high_risk: 0
                        unverifiable: 0
                        changed: 1
                        newly_flagged: 1
                        credits_used: 2
                        stopped_reason: null
                      created_at: '2026-08-05T08:00:00+00:00'
                meta:
                  page: 1
                  per_page: 100
                  total: 1
                  retention_days: 365
        401:
          $ref: '#/components/responses/Unauthenticated'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/address/monitors \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/monitors', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/address/monitors',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/monitors');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.list_address_monitors()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.listAddressMonitors();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->listAddressMonitors();
    post:
      operationId: create-address-monitor
      tags:
        - Address
      summary: 'Monitor a list of addresses on a schedule'
      description: |
        Saves up to 500 addresses — your own sites, a supplier's registered offices, the delivery addresses behind an order book — and re-checks them every week or month. There is no daily cadence: the national registers and postcode directories behind an address answer are refreshed weekly at best, so a daily run would bill for data that cannot have changed.

        Each item is a written address as a plain string, or an object of the same fields the single lookup takes, and may name its own `country`; the list-level `country` applies to the rest. The first run starts right away and only sets the baseline. From the next run on, every address that newly leaves the national register, has its postcode terminated, becomes a mail drop, starts scoring high, or stops standing as written is reported to the account's email with the reason, so a customer's address goes stale in your inbox rather than in your post room.

        Answers `201` with the monitor. The baseline run is queued, not finished: poll the monitor for `last_run_at` and `last_summary`.

        With a `webhook_url`, a finished run is POSTed there as `{ "event": "address_monitor.run", "monitor": { "id", "name", "cadence", "last_run_at", "next_run_at" }, "summary": { … the same block as `last_summary` … }, "changes": [ … ] }`, where `changes` names the addresses the alert email names. The `X-Spaw-Signature-V2` header carries `t={unix seconds},v2={lowercase hex}`, where the hex is the HMAC-SHA256 of `v2:{t}:{raw body}` keyed with `webhook_secret`, which this response returns once; a receiver should refuse anything whose `t` is more than five minutes from its own clock, since a signature over the body alone would let a captured delivery be replayed at it forever. The original `X-Spaw-Signature`, that HMAC of the body alone, is sent beside it until 1 March 2027. Connection errors and 5xx answers are retried twice with a short backoff, three attempts in all, and the outcome is readable afterwards as `webhook_status` and `webhook_detail`. A URL that resolves onto a private network is never posted to.
      x-spaw-auth: bearer
      x-spaw-billing: 'Creation is free. Each run bills every address like a single lookup: 1 credit for a fresh answer that stands as written; addresses that cannot, and 7-day repeats, are free. With `deliverability`, the premium is added only where the licensed partner answered.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
                - addresses
                - cadence
              properties:
                name:
                  type: string
                  maxLength: 100
                  description: 'A label for the alerts.'
                addresses:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type:
                      - string
                      - object
                    maxLength: 500
                    properties:
                      address:
                        type:
                          - string
                          - 'null'
                        maxLength: 500
                        description: 'The whole address written as it would be on an envelope.'
                      address_line1:
                        type:
                          - string
                          - 'null'
                        maxLength: 255
                        description: 'The street line, when the address arrives separated.'
                      address_line2:
                        type:
                          - string
                          - 'null'
                        maxLength: 255
                      organization:
                        type:
                          - string
                          - 'null'
                        maxLength: 200
                      building:
                        type:
                          - string
                          - 'null'
                        maxLength: 100
                      dependent_locality:
                        type:
                          - string
                          - 'null'
                        maxLength: 100
                      locality:
                        type:
                          - string
                          - 'null'
                        maxLength: 100
                      administrative_area:
                        type:
                          - string
                          - 'null'
                        maxLength: 100
                      postal_code:
                        type:
                          - string
                          - 'null'
                        maxLength: 32
                      po_box:
                        type:
                          - string
                          - 'null'
                        maxLength: 64
                      country:
                        type:
                          - string
                          - 'null'
                        maxLength: 2
                        description: "This item's own country. Wins over the list-level one."
                  description: '1 to 500 addresses. Each item is a written address as a string, or an object of the same fields the single lookup takes.'
                cadence:
                  type: string
                  enum:
                    - weekly
                    - monthly
                  description: 'How often the list is re-checked. There is no daily cadence.'
                country:
                  type:
                    - string
                    - 'null'
                  maxLength: 2
                  description: 'An ISO 3166-1 alpha-2 code applied to every entry that does not name its own.'
                deliverability:
                  type: boolean
                  default: false
                  description: 'Ask the licensed partner whether post arrives at every address on every run. While no partner is enabled it answers `deliverability_checked` false at no extra cost.'
                webhook_url:
                  type:
                    - string
                    - 'null'
                  maxLength: 2048
                  format: uri
                  description: 'An https URL every reported run is POSTed to. The signing secret is returned once, in this response, and never again.'
                webhook_events:
                  type: string
                  enum:
                    - changes
                    - every_run
                  default: changes
                  description: 'When to post. `changes` posts only when the run found something new — decay, a status change, a newly flagged entry — or stopped early; `every_run` posts after every run, which also says the monitor is still running.'
            example:
              name: 'Supplier offices'
              addresses:
                - '221B Baker Street, London, NW1 6XE'
                -
                  address_line1: '10 Downing Street'
                  locality: London
                  postal_code: 'SW1A 2AA'
              cadence: monthly
              country: GB
      responses:
        201:
          description: 'The monitor was saved and its baseline run queued.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        allOf:
                          -
                            $ref: '#/components/schemas/AddressMonitor'
                          -
                            type: object
                            properties:
                              webhook_secret:
                                type:
                                  - string
                                  - 'null'
                                description: 'The webhook signing secret, shown once here and never readable again. Null when the monitor has no webhook.'
              example:
                success: true
                data:
                  monitor:
                    id: 9
                    name: 'Supplier offices'
                    cadence: monthly
                    address_count: 2
                    country: GB
                    deliverability: false
                    next_run_at: '2026-10-05T10:12:44+00:00'
                    last_run_at: null
                    last_summary: null
                    created_at: '2026-09-05T10:12:44+00:00'
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/address/monitors \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "name": "Supplier offices",
              "addresses": [
                "221B Baker Street, London, NW1 6XE",
                {
                  "address_line1": "10 Downing Street",
                  "locality": "London",
                  "postal_code": "SW1A 2AA"
                }
              ],
              "cadence": "monthly",
              "country": "GB"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/monitors', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "name": "Supplier offices",
                "addresses": [
                  "221B Baker Street, London, NW1 6XE",
                  {
                    "address_line1": "10 Downing Street",
                    "locality": "London",
                    "postal_code": "SW1A 2AA"
                  }
                ],
                "cadence": "monthly",
                "country": "GB"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/address/monitors',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'name': 'Supplier offices',
                    'addresses': [
                        '221B Baker Street, London, NW1 6XE',
                        {
                            'address_line1': '10 Downing Street',
                            'locality': 'London',
                            'postal_code': 'SW1A 2AA'
                        }
                    ],
                    'cadence': 'monthly',
                    'country': 'GB'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/monitors');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'name' => 'Supplier offices',
                    'addresses' => [
                        '221B Baker Street, London, NW1 6XE',
                        [
                            'address_line1' => '10 Downing Street',
                            'locality' => 'London',
                            'postal_code' => 'SW1A 2AA'
                        ]
                    ],
                    'cadence' => 'monthly',
                    'country' => 'GB'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.create_address_monitor(name='Supplier offices', addresses=[
                '221B Baker Street, London, NW1 6XE',
                {
                    'address_line1': '10 Downing Street',
                    'locality': 'London',
                    'postal_code': 'SW1A 2AA'
                }
            ], cadence='monthly', country='GB')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.createAddressMonitor({
                name: 'Supplier offices',
                addresses: [
                    '221B Baker Street, London, NW1 6XE',
                    {
                        address_line1: '10 Downing Street',
                        locality: 'London',
                        postal_code: 'SW1A 2AA'
                    }
                ],
                cadence: 'monthly',
                country: 'GB'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->createAddressMonitor(name: 'Supplier offices', addresses: [
                '221B Baker Street, London, NW1 6XE',
                [
                    'address_line1' => '10 Downing Street',
                    'locality' => 'London',
                    'postal_code' => 'SW1A 2AA'
                ]
            ], cadence: 'monthly', country: 'GB');
  '/api/v1/address/monitors/{monitorId}':
    get:
      operationId: get-address-monitor
      tags:
        - Address
      summary: 'Read an address monitor and its last results'
      description: |
        The monitor plus `results`: for every address from the last run, its status, the conditions that fired, what the registers answered, its score and level, and whether the status changed since the previous run; keyed by the address as it was saved, or null until the first run has finished.

        The status is the first condition that fired, in this order: `invalid` (the address no longer stands as written, with the `reason`), `not_in_register` (a register whose coverage is complete no longer carries it), `terminated_postcode`, `mail_drop`, `high_risk`. An address that fired none is `ok`, or `unverifiable` when the answer stands but no register, postcode directory or company register is installed for its country — which is the honest answer, and is counted apart so you can see how much of a list nobody can check. A monitor that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
          example: 9
          description: "The monitor's id from the list endpoint."
      responses:
        200:
          description: 'The monitor.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        allOf:
                          -
                            $ref: '#/components/schemas/AddressMonitor'
                          -
                            type: object
                            properties:
                              results:
                                type:
                                  - object
                                  - 'null'
                                additionalProperties:
                                  $ref: '#/components/schemas/AddressMonitorResult'
                                description: "The last run's answer per address, keyed by the address as it was saved; null until the first run finishes."
              example:
                success: true
                data:
                  monitor:
                    id: 9
                    name: 'Supplier offices'
                    cadence: monthly
                    address_count: 2
                    country: GB
                    deliverability: false
                    next_run_at: '2026-10-05T08:00:00+00:00'
                    last_run_at: '2026-09-05T08:00:04+00:00'
                    last_summary:
                      total: 2
                      ok: 1
                      invalid: 0
                      not_in_register: 1
                      terminated_postcode: 0
                      mail_drop: 0
                      high_risk: 0
                      unverifiable: 0
                      changed: 1
                      newly_flagged: 1
                      credits_used: 2
                      stopped_reason: null
                    created_at: '2026-08-05T08:00:00+00:00'
                    results:
                      '221B Baker Street, London, NW1 6XE':
                        status: not_in_register
                        flags:
                          - not_in_register
                        reason: null
                        exists: false
                        postcode_status: live
                        companies_registered: 0
                        is_mail_drop: false
                        risk_score: 20
                        risk_level: low
                        changed: true
                      '10 Downing Street, London, SW1A 2AA':
                        status: ok
                        flags: []
                        reason: null
                        exists: true
                        postcode_status: live
                        companies_registered: 0
                        is_mail_drop: false
                        risk_score: 0
                        risk_level: low
                        changed: false
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/address/monitors/9 \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/monitors/9', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/address/monitors/9',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/monitors/9');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.get_address_monitor(9)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.getAddressMonitor(9);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->getAddressMonitor(9);
    patch:
      operationId: update-address-monitor
      tags:
        - Address
      summary: 'Change a monitor'
      description: |
        Changes a saved monitor in place: rename it, move it to another cadence, replace the addresses, or point it at a webhook. Send only the fields you are changing; a body that names none of them answers `422` rather than quietly changing nothing, because that is the shape a misspelled field name takes.

        Replacing `addresses` replaces the list. What the last run recorded about the addresses that stayed is kept, so their next run still compares against the answer they already had; what it recorded about the ones that are gone is deleted with them; and an entry that is new starts with no baseline, so its first run only sets one. A rename never touches a stored answer — they are keyed by the entry, never by the list.

        Changing `cadence` neither skips a run nor starts a second one: a run already scheduled sooner than the new interval still happens, and a longer interval applies from the one after it. Nothing is queued by this call — the change applies to the next run, and `POST .../run` is there to have it now.

        Sending a `webhook_url` that differs from the one stored mints a new signing secret and returns it once, here; sending the URL it already has keeps the secret it is signing with, and `null` removes the webhook. A monitor that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: 'Free. The change applies to the next run.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - VALIDATION_FAILED
        - RATE_LIMITED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
          example: 41
          description: "The monitor's id from the list endpoint."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                name:
                  type: string
                  maxLength: 100
                addresses:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type:
                      - string
                      - object
                cadence:
                  type: string
                  enum:
                    - weekly
                    - monthly
                country:
                  type:
                    - string
                    - 'null'
                  description: 'The country entries that name none belong to.'
                deliverability:
                  type: boolean
                webhook_url:
                  type:
                    - string
                    - 'null'
                  maxLength: 2048
                  format: uri
                webhook_events:
                  type: string
                  enum:
                    - changes
                    - every_run
            example:
              name: 'Supplier offices (UK)'
              country: GB
      responses:
        200:
          description: 'The monitor as it now stands.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        allOf:
                          -
                            $ref: '#/components/schemas/AddressMonitor'
                          -
                            type: object
                            properties:
                              webhook_secret:
                                type: string
                                description: 'Present only when this call pointed the monitor at a URL it was not already posting to. Shown once.'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X PATCH https://spaw.co/api/v1/address/monitors/41 \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "name": "Supplier offices (UK)",
              "country": "GB"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/monitors/41', {
              method: 'PATCH',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "name": "Supplier offices (UK)",
                "country": "GB"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.patch(
                'https://spaw.co/api/v1/address/monitors/41',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'name': 'Supplier offices (UK)',
                    'country': 'GB'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/monitors/41');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'PATCH',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'name' => 'Supplier offices (UK)',
                    'country' => 'GB'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.update_address_monitor(41, name='Supplier offices (UK)', country='GB')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.updateAddressMonitor(41, {
                name: 'Supplier offices (UK)',
                country: 'GB'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->updateAddressMonitor(41, [
                'name' => 'Supplier offices (UK)',
                'country' => 'GB'
            ]);
    delete:
      operationId: delete-address-monitor
      tags:
        - Address
      summary: 'Stop monitoring an address list'
      description: |
        Removes the monitor and its stored results; nothing further is scheduled. A run that is already queued finishes on its own and bills as usual. A monitor that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
          example: 9
          description: "The monitor's id from the list endpoint."
      responses:
        200:
          description: 'The monitor was removed.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
              example:
                success: true
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X DELETE https://spaw.co/api/v1/address/monitors/9 \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/monitors/9', {
              method: 'DELETE',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.delete(
                'https://spaw.co/api/v1/address/monitors/9',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/monitors/9');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'DELETE',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.delete_address_monitor(9)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.deleteAddressMonitor(9);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->deleteAddressMonitor(9);
  '/api/v1/address/monitors/{monitorId}/run':
    post:
      operationId: run-address-monitor
      tags:
        - Address
      summary: 'Re-check a monitored address list now'
      description: |
        Queues a run right away instead of waiting for the schedule; when it finishes, the next scheduled run is set one cadence from then. Answers `202` with the monitor as it stands before the run; poll it for the new `last_run_at`, `last_summary` and `results`. A monitor that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: 'The run bills every address like a single lookup; addresses that do not stand as written, and 7-day repeats, are free.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
          example: 9
          description: "The monitor's id from the list endpoint."
      responses:
        202:
          description: 'The run was queued.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        $ref: '#/components/schemas/AddressMonitor'
              example:
                success: true
                data:
                  monitor:
                    id: 9
                    name: 'Supplier offices'
                    cadence: monthly
                    address_count: 2
                    country: GB
                    deliverability: false
                    next_run_at: '2026-10-05T08:00:00+00:00'
                    last_run_at: '2026-09-05T08:00:04+00:00'
                    last_summary:
                      total: 2
                      ok: 1
                      invalid: 0
                      not_in_register: 1
                      terminated_postcode: 0
                      mail_drop: 0
                      high_risk: 0
                      unverifiable: 0
                      changed: 1
                      newly_flagged: 1
                      credits_used: 2
                      stopped_reason: null
                    created_at: '2026-08-05T08:00:00+00:00'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/address/monitors/9/run \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/address/monitors/9/run', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/address/monitors/9/run',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/address/monitors/9/run');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.run_address_monitor(9)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.runAddressMonitor(9);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->runAddressMonitor(9);
  /api/v1/consistency:
    post:
      operationId: check-consistency
      tags:
        - Consistency
      summary: 'Check several signals against each other'
      description: |
        Takes any subset of an email address, a phone number, an IP address, a postal address and a business identifier, runs each through the product that owns it, and reports where the answers agree and where they do not.

        The per-signal blocks under `signals` are exactly what the single-lookup endpoints return, field for field. All five keys are always present: a signal you did not ask for, or one the call did not reach, is `null`. The signals run in a fixed order — `ip`, `phone`, `entity`, `address`, `email` — because a keyed body has no input order, and because email is the only leg that does DNS and a mailbox probe, so a call that runs out of credits stops at the slowest leg last.

        The `consistency` block on top is free. `comparisons` lists each check with both sides quoted — the signal, the field and the value that decided it — so nothing is asserted that you cannot see the working for. A comparison appears only when both of its signals answered; a signal nobody asked about produces silence, not a row. Where one side is `null` the result is `not_compared` with `withheld: "missing_value"`, never a disagreement, because a null field means "not evaluated" and never "no". Where the IP is a VPN exit, a relay or a datacenter egress, the country comparisons answer `not_compared` with `withheld: "low_confidence"`: that location belongs to the operator rather than to the person, and reporting a difference there would manufacture evidence against everyone who uses a VPN.

        `country_agreement` rolls up the three country comparisons between the person's own signals — `differ` if any of them differed, `agree` if at least one agreed and none differed, and `null` when none of them could be made. `flags` names the disagreements and the pairings that held: `phone_country_differs_from_address_country`, `ip_country_differs_from_address_country`, `ip_country_differs_from_phone_country`, `disposable_email_from_datacenter_ip`, `disposable_email_from_anonymous_network`, `mail_drop_address_with_free_email_provider`, `disposable_phone_with_disposable_email`, `entity_country_differs_from_address_country`, `entity_name_differs_from_address_organization`. The `ip_address_distance` comparison carries `distance_km` as evidence only, and never raises a flag: there is no honest distance at which a person stops being themselves.

        The business identifier is compared against the postal address, the only other signal carrying a company name and a country of its own. `entity_address_country` puts the country of registration beside the country the address stands in; it is deliberately **not** part of `country_agreement`, because where a company is registered is not where a person is, and a Delaware company with a London office would otherwise read as a top-level disagreement. `entity_address_organization` compares the registered legal name with the organisation written on the address, and the only claim it makes is a normalised exact match: both sides are folded to their letters and digits and compared as strings, with case, accents (`Société` and `Societe` fold together), an ampersand against the word `and`, apostrophes, full stops in initials, and company-form words such as limited, ltd, plc, llp and inc taken out first — so a difference here is a difference in the name rather than in how it was typed. There is no similarity score, because no threshold honestly decides whether two names are one company — so `differ` means the two names are not the same name, not that the address belongs to somebody else. A value that folds away to nothing, such as an organisation line reading only "Ltd", withholds the comparison as a missing value.

        There is no score, no risk level, no verdict and no recommendation, and there will not be one. The flags are the whole output, and a difference between two fields is a fact about the fields, not a judgement about a person — people travel, move house, keep a foreign number and browse through a VPN. Nothing about the call is stored beyond what each product already logs for its own lookup.
      x-spaw-auth: bearer
      x-spaw-billing: 'Each signal bills exactly as its own single-lookup endpoint: 1 credit per definitive answer, free when the answer is no, and free for a 7-day repeat of the same input on the same account. The IP signal bills one credit per five answered IP lookups, so it usually costs 0 (`ip_lookups_prepaid` on its meta), an email answer whose mailbox check was uncertain is free, and an undeliverable one is free within your email fair use (`fair_use_billed` on its meta). `meta.credits_used` is the sum, and the consistency analysis itself is free. A call that runs out of credits part-way keeps the signals it paid for and reports `meta.stopped_reason`; only a call refused on its first signal answers 402.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - INSUFFICIENT_CREDITS
        - RATE_LIMITED
        - KEY_SCOPE_DENIED
        - EMAIL_NOT_VERIFIED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: 'At least one of `email`, `phone`, `ip`, `address` and `entity` is required.'
              properties:
                email:
                  type:
                    - string
                    - 'null'
                  maxLength: 254
                  description: 'The email address to verify.'
                phone:
                  type:
                    - string
                    - 'null'
                  maxLength: 64
                  description: 'The phone number to validate, in E.164 or in national notation with `country`.'
                country:
                  type:
                    - string
                    - 'null'
                  maxLength: 2
                  description: 'The ISO region the phone number should be read against. It applies to the phone signal only; the postal address carries its own country.'
                ip:
                  type:
                    - string
                    - 'null'
                  description: 'The IPv4 or IPv6 address the request came from.'
                address:
                  type:
                    - object
                    - 'null'
                  description: "The postal address, in the same shape `POST /api/v1/address` takes — either a written `address` or the separated fields, always with `country`. Send `organization` to have the company name compared with the register's."
                entity:
                  type:
                    - string
                    - object
                  description: 'The business identifier, in the same shape `POST /api/v1/entity` takes. A bare string is read as a LEI, which is the only type needing no country.'
                  properties:
                    identifier:
                      type: string
                      maxLength: 64
                    type:
                      type: string
                      enum:
                        - lei
                        - company_number
                    country:
                      type:
                        - string
                        - 'null'
                      maxLength: 2
                      description: 'Required when `type` is `company_number`.'
            example:
              email: ada@example.com
              phone: '+442079460018'
              country: GB
              ip: 81.2.69.142
              address:
                address: '10 Downing Street, London, SW1A 2AA'
                organization: 'ACME HOLDINGS LTD'
                country: GB
              entity:
                identifier: 213800QILIUD4ROSUO03
                type: lei
      responses:
        200:
          description: "Each signal's own answer, and the comparisons between them."
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      signals:
                        type: object
                        properties:
                          email:
                            type:
                              - object
                              - 'null'
                            properties:
                              data:
                                $ref: '#/components/schemas/EmailResult'
                              meta:
                                $ref: '#/components/schemas/SignalMeta'
                          phone:
                            type:
                              - object
                              - 'null'
                            properties:
                              data:
                                $ref: '#/components/schemas/PhoneResult'
                              meta:
                                $ref: '#/components/schemas/SignalMeta'
                          ip:
                            type:
                              - object
                              - 'null'
                            properties:
                              data:
                                $ref: '#/components/schemas/IpResult'
                              meta:
                                $ref: '#/components/schemas/SignalMeta'
                          address:
                            type:
                              - object
                              - 'null'
                            properties:
                              data:
                                $ref: '#/components/schemas/AddressResult'
                              meta:
                                $ref: '#/components/schemas/SignalMeta'
                          entity:
                            type:
                              - object
                              - 'null'
                            properties:
                              data:
                                $ref: '#/components/schemas/EntityResult'
                              meta:
                                $ref: '#/components/schemas/SignalMeta'
                      consistency:
                        $ref: '#/components/schemas/ConsistencyResult'
                  meta:
                    type: object
                    properties:
                      requested:
                        type: integer
                        description: 'How many signals the body asked for.'
                      processed:
                        type: integer
                        description: 'How many of them answered.'
                      credits_used:
                        type: integer
                        description: 'The sum across the signals; the consistency analysis is free.'
                      credits_remaining:
                        type: integer
                      stopped_reason:
                        type:
                          - string
                          - 'null'
                        enum:
                          - insufficient_credits
                          - null
                      request_id:
                        type:
                          - string
                          - 'null'
              example:
                success: true
                data:
                  consistency:
                    signals_compared:
                      - email
                      - phone
                      - ip
                      - address
                      - entity
                    country_agreement: differ
                    flags:
                      - ip_country_differs_from_address_country
                      - ip_country_differs_from_phone_country
                    comparisons:
                      -
                        comparison: phone_address_country
                        left:
                          signal: phone
                          field: country
                          value: GB
                        right:
                          signal: address
                          field: country
                          value: GB
                        result: agree
                        withheld: null
                        flag: null
                        distance_km: null
                      -
                        comparison: ip_address_country
                        left:
                          signal: ip
                          field: country
                          value: DE
                        right:
                          signal: address
                          field: country
                          value: GB
                        result: differ
                        withheld: null
                        flag: ip_country_differs_from_address_country
                        distance_km: null
                meta:
                  requested: 5
                  processed: 5
                  credits_used: 5
                  credits_remaining: 995
                  stopped_reason: null
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/consistency \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "email": "ada@example.com",
              "phone": "+442079460018",
              "country": "GB",
              "ip": "81.2.69.142",
              "address": {
                "address": "10 Downing Street, London, SW1A 2AA",
                "organization": "ACME HOLDINGS LTD",
                "country": "GB"
              },
              "entity": {
                "identifier": "213800QILIUD4ROSUO03",
                "type": "lei"
              }
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/consistency', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "email": "ada@example.com",
                "phone": "+442079460018",
                "country": "GB",
                "ip": "81.2.69.142",
                "address": {
                  "address": "10 Downing Street, London, SW1A 2AA",
                  "organization": "ACME HOLDINGS LTD",
                  "country": "GB"
                },
                "entity": {
                  "identifier": "213800QILIUD4ROSUO03",
                  "type": "lei"
                }
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/consistency',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'email': 'ada@example.com',
                    'phone': '+442079460018',
                    'country': 'GB',
                    'ip': '81.2.69.142',
                    'address': {
                        'address': '10 Downing Street, London, SW1A 2AA',
                        'organization': 'ACME HOLDINGS LTD',
                        'country': 'GB'
                    },
                    'entity': {
                        'identifier': '213800QILIUD4ROSUO03',
                        'type': 'lei'
                    }
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/consistency');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'email' => 'ada@example.com',
                    'phone' => '+442079460018',
                    'country' => 'GB',
                    'ip' => '81.2.69.142',
                    'address' => [
                        'address' => '10 Downing Street, London, SW1A 2AA',
                        'organization' => 'ACME HOLDINGS LTD',
                        'country' => 'GB'
                    ],
                    'entity' => [
                        'identifier' => '213800QILIUD4ROSUO03',
                        'type' => 'lei'
                    ]
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.check_consistency(email='ada@example.com', phone='+442079460018', country='GB', ip='81.2.69.142', address={
                'address': '10 Downing Street, London, SW1A 2AA',
                'organization': 'ACME HOLDINGS LTD',
                'country': 'GB'
            })
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.checkConsistency({
                email: 'ada@example.com',
                phone: '+442079460018',
                country: 'GB',
                ip: '81.2.69.142',
                address: {
                    address: '10 Downing Street, London, SW1A 2AA',
                    organization: 'ACME HOLDINGS LTD',
                    country: 'GB'
                }
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->checkConsistency(email: 'ada@example.com', phone: '+442079460018', country: 'GB', ip: '81.2.69.142', address: [
                'address' => '10 Downing Street, London, SW1A 2AA',
                'organization' => 'ACME HOLDINGS LTD',
                'country' => 'GB'
            ]);
  /api/v1/consistency/bulk:
    get:
      operationId: list-consistency-bulk-jobs
      tags:
        - Consistency
      summary: "List the account's bulk consistency runs"
      description: |
        Every bulk consistency run this account has started, newest first, 100 per page. A job id is handed out once, at creation, and everything about a run hangs off it — its counts, its results, its cancel and resume calls — so this is how a caller that lost one finds the run again.

        `status`, `since` and `until` are optional and combine; a bare date in `until` means the whole of that day, and an unknown status or an unparseable date is a `422` rather than an empty page. The webhook secret is never among the rows: it is returned once, at creation.

        Runs are kept as long as the lookup history, and `meta.retention_days` says how long that is — a run older than that is deleted with its files and cannot be listed.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
      parameters:
        -
          name: status
          in: query
          required: false
          schema:
            type: string
            enum:
              - queued
              - processing
              - completed
              - failed
              - cancelled
          example: completed
          description: 'Only runs in this status.'
        -
          name: since
          in: query
          required: false
          schema:
            type: string
          example: '2026-09-01'
          description: 'Only runs created at or after this date or timestamp.'
        -
          name: until
          in: query
          required: false
          schema:
            type: string
          example: '2026-09-10'
          description: 'Only runs created at or before this. A bare date means the whole of that day.'
        -
          name: page
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            default: 1
          example: 1
          description: 'The page to return.'
      responses:
        200:
          description: 'One page of runs, newest first.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      jobs:
                        type: array
                        items:
                          $ref: '#/components/schemas/ConsistencyBulkJob'
                  meta:
                    type: object
                    properties:
                      page:
                        type: integer
                      per_page:
                        type: integer
                      total:
                        type: integer
                      retention_days:
                        type: integer
                        description: 'How long a run and its files are kept before they are deleted.'
              example:
                success: true
                data:
                  jobs:
                    -
                      id: 41
                      status: completed
                      total: 900
                      duplicate_count: 3
                      processed: 900
                      flagged: 61
                      unflagged: 802
                      uncompared: 37
                      credits_used: 2410
                      stopped_reason: null
                      cancel_requested: false
                      country: GB
                      webhook_status: null
                      webhook_detail: null
                      created_at: '2026-09-10T11:30:00+00:00'
                      finished_at: '2026-09-10T11:36:12+00:00'
                meta:
                  page: 1
                  per_page: 100
                  total: 1
                  retention_days: 30
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/consistency/bulk \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/consistency/bulk', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/consistency/bulk',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/consistency/bulk');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.list_consistency_bulk_jobs()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.listConsistencyBulkJobs();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->listConsistencyBulkJobs();
    post:
      operationId: create-consistency-bulk-job
      tags:
        - Consistency
      summary: 'Queue a bulk consistency run'
      description: |
        The cross-signal check over a whole list. Queues up to 100,000 rows and processes them in the background: creation answers `202` with the job's id and status, `GET /api/v1/consistency/bulk/{jobId}` reports progress, and `GET /api/v1/consistency/bulk/{jobId}/results` streams one CSV row per input row — the four signals as submitted, then what agreed, what did not, and what could not honestly be settled.

        Each item carries any subset of `email`, `phone`, `ip` and `address`, and at least one of them. **A row that carries two of the four is a perfectly good consistency check of those two** and is run as one; the comparisons its missing signals would have been part of are simply absent from the row, in the same way a signal you did not send produces silence rather than a row on the single call. A row's `country` applies to that row alone and wins over the list-level one.

        Rows repeating an ENTIRE row already in the list are counted in `duplicate_count`. That is stricter than the other four bulk runs on purpose: repeats are free per lookup here, not per row, so two rows sharing an email address but naming different phone numbers get one free leg and one charged one — counting them as duplicates would take a credit off the preview's ceiling that the run then spends.

        With a `webhook_url`, the finish (`completed`, `failed`, or `cancelled`) is POSTed there as `{ "event": "bulk_consistency_job.finished", "job": { … } }`, signed with `webhook_secret` exactly like the other four bulk runs: `X-Spaw-Signature-V2` carries `t={unix seconds},v2={lowercase hex}`, the HMAC-SHA256 of `v2:{t}:{raw body}`, and a receiver should refuse a `t` more than five minutes from its own clock so a captured delivery cannot be replayed at it later. The original `X-Spaw-Signature`, over the body alone, is sent beside it until 1 March 2027. Send an `Idempotency-Key` header to make creation safe to retry: a repeat of the same request answers the job the first attempt created, as `200` with `Idempotent-Replayed: true`; the same key with a different request answers `409 IDEMPOTENCY_KEY_REUSED`.

        ### Reading a list before you start it

        Send `preview: true` with the same body — or `csv` with a raw CSV instead of the item list — and the call answers `200` with `data.draft` instead of queueing anything: what the list was read as, which column filled which signal and whether a heading named it or we guessed, the first rows as parsed, the repeats, the balance, and `credits_upper_bound`. Nothing is charged. The mapping matters more here than anywhere else, because a row is up to four lookups: `credits_per_row` is the number of SIGNALS the columns feed, so a file whose columns only reach an email address and a phone number is priced at two a row rather than four.

        `credits_upper_bound` is a CEILING, not a quote. Rows come back free for reasons no preview can know in advance — a leg that repeats inside the seven-day window, a verdict that is not charged for, a signal a row turns out not to carry — so a run usually costs less than the ceiling and never more.

        Start it with `{"draft_id": <id>}`, adding `mapping` to correct a column we read wrongly. Starting consumes the draft; one left unstarted is deleted, with the rows it holds, after `retention_hours`.
      x-spaw-auth: bearer
      x-spaw-billing: "`preview: true` is free and queues nothing. A started run bills each row as the SUM OF THE LEGS IT ACTUALLY RAN, each on that product's own rules — 1 credit for a definitive answer, free when the answer is no, free for a 7-day repeat of the same input on the same account — and the comparison on top is free. So a row costs between nothing and four credits, which is exactly what the same rows would have cost one at a time through `POST /api/v1/consistency`. None of the per-product extras is available here and none is charged: no HLR, no deliverability partner, no privacy mode. Suppression lists are deliberately not consulted, matching the single call: a consistency check is a fresh vetting decision."
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - IDEMPOTENCY_KEY_REUSED
        - RATE_LIMITED
        - KEY_SCOPE_DENIED
        - EMAIL_NOT_VERIFIED
      parameters:
        -
          name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            minLength: 1
            maxLength: 128
          description: 'A value unique to this request, so a retry after a timeout answers the same job instead of queueing a second one.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 100000
                  items:
                    type: object
                    properties:
                      email:
                        type:
                          - string
                          - 'null'
                        maxLength: 254
                      phone:
                        type:
                          - string
                          - 'null'
                        maxLength: 64
                        description: 'In E.164, or in national notation with a country.'
                      ip:
                        type:
                          - string
                          - 'null'
                        description: 'An IPv4 or IPv6 address.'
                      address:
                        type:
                          - string
                          - object
                          - 'null'
                        description: "A written address, or an object of the same separated fields `POST /api/v1/address` takes. Its own `country` wins over the item's."
                      country:
                        type:
                          - string
                          - 'null'
                        maxLength: 2
                        description: "This row's country. It serves two legs — the region the phone number is read against and the country the postal address is measured against — and each leg ignores it when its own table does not cover it."
                  description: '1 to 100,000 rows, each naming at least one of the four signals. Required unless the body carries `csv` with `preview: true`, or `draft_id`.'
                country:
                  type:
                    - string
                    - 'null'
                  maxLength: 2
                  description: 'An ISO 3166-1 alpha-2 code applied to every row that does not name its own. It has to be known to the dialling plan or to the postal format table; a code neither knows is a `422` rather than a setting that quietly does nothing.'
                preview:
                  type: boolean
                  default: false
                  description: 'Read the list and answer `data.draft` — what we made of it, the repeats, the ceiling on the cost and the balance — with nothing queued and nothing billed. Start it afterwards with `draft_id`.'
                csv:
                  type:
                    - string
                    - 'null'
                  maxLength: 5000000
                  description: "A raw CSV to read instead of the item list, with the column mapping answered back. Preview only, on purpose: one header row has to yield an email address, a phone number, an IP address and the address fields at once, and guessing at a customer's columns and billing them for the guess in the same request is what the preview exists to stop."
                draft_id:
                  type:
                    - integer
                    - 'null'
                  description: 'Start the run a preview answered with. The rows and the options come from the draft, so nothing else in the body applies except `mapping`. A draft can be started once; an `Idempotency-Key` still makes the call safe to retry.'
                mapping:
                  type:
                    - object
                    - 'null'
                  additionalProperties:
                    type: string
                  description: 'Corrects the draft''s column mapping, as column index to field — {"1": "email", "4": "address_line1"}. The fields are `email`, `phone`, `ip`, `country` and the address ones (`address`, `address_line1`, `address_line2`, `organization`, `dependent_locality`, `locality`, `administrative_area`, `postal_code`, `po_box`). It REPLACES the detected mapping rather than merging into it, so a column can be taken off a field as well as put on one. A mapping that feeds none of the four signals is refused: a country column on its own checks nothing.'
                webhook_url:
                  type:
                    - string
                    - 'null'
                  format: uri
                  description: 'An https URL to notify when the run finishes.'
            example:
              items:
                -
                  email: ada@acme.com
                  phone: '+442079460018'
                  ip: 81.2.69.142
                  address: '10 Downing Street, London, SW1A 2AA'
                  country: GB
                -
                  email: grace@acme.com
                  ip: 81.2.69.143
              country: GB
              webhook_url: 'https://example.com/hooks/spaw'
      responses:
        202:
          description: 'The job was queued.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        allOf:
                          -
                            $ref: '#/components/schemas/ConsistencyBulkJob'
                          -
                            type: object
                            properties:
                              webhook_secret:
                                type:
                                  - string
                                  - 'null'
                                description: 'Returned once. Verify webhook signatures with it.'
              example:
                success: true
                data:
                  job:
                    id: 41
                    status: queued
                    total: 2
                    duplicate_count: 0
                    processed: 0
                    flagged: 0
                    unflagged: 0
                    uncompared: 0
                    credits_used: 0
                    stopped_reason: null
                    cancel_requested: false
                    country: GB
                    webhook_status: null
                    webhook_detail: null
                    created_at: '2026-09-10T10:12:44+00:00'
                    finished_at: null
                    webhook_secret: '8fJ2…40 characters…Qk1'
        200:
          description: 'Either a preview — `preview: true` answers `data.draft` and queues nothing — or a retry with an `Idempotency-Key` already used for this exact request, which answers `data.job` with `Idempotent-Replayed: true`.'
          headers:
            Idempotent-Replayed:
              schema:
                type: string
                enum:
                  - 'true'
              description: 'Present only on a replayed creation.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      draft:
                        $ref: '#/components/schemas/BulkDraft'
                      job:
                        $ref: '#/components/schemas/ConsistencyBulkJob'
              example:
                success: true
                data:
                  job:
                    id: 41
                    status: processing
                    total: 2
                    duplicate_count: 0
                    processed: 1
                    flagged: 1
                    unflagged: 0
                    uncompared: 0
                    credits_used: 3
                    stopped_reason: null
                    cancel_requested: false
                    country: GB
                    webhook_status: null
                    webhook_detail: null
                    created_at: '2026-09-10T10:12:44+00:00'
                    finished_at: null
        401:
          $ref: '#/components/responses/Unauthenticated'
        409:
          description: 'The `Idempotency-Key` was already used for a different list, country or webhook URL.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: IDEMPOTENCY_KEY_REUSED
                  message: 'This Idempotency-Key was already used for a different request.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/consistency/bulk \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "items": [
                {
                  "email": "ada@acme.com",
                  "phone": "+442079460018",
                  "ip": "81.2.69.142",
                  "address": "10 Downing Street, London, SW1A 2AA",
                  "country": "GB"
                },
                {
                  "email": "grace@acme.com",
                  "ip": "81.2.69.143"
                }
              ],
              "country": "GB",
              "webhook_url": "https://example.com/hooks/spaw"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/consistency/bulk', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "items": [
                  {
                    "email": "ada@acme.com",
                    "phone": "+442079460018",
                    "ip": "81.2.69.142",
                    "address": "10 Downing Street, London, SW1A 2AA",
                    "country": "GB"
                  },
                  {
                    "email": "grace@acme.com",
                    "ip": "81.2.69.143"
                  }
                ],
                "country": "GB",
                "webhook_url": "https://example.com/hooks/spaw"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/consistency/bulk',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'items': [
                        {
                            'email': 'ada@acme.com',
                            'phone': '+442079460018',
                            'ip': '81.2.69.142',
                            'address': '10 Downing Street, London, SW1A 2AA',
                            'country': 'GB'
                        },
                        {
                            'email': 'grace@acme.com',
                            'ip': '81.2.69.143'
                        }
                    ],
                    'country': 'GB',
                    'webhook_url': 'https://example.com/hooks/spaw'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/consistency/bulk');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'items' => [
                        [
                            'email' => 'ada@acme.com',
                            'phone' => '+442079460018',
                            'ip' => '81.2.69.142',
                            'address' => '10 Downing Street, London, SW1A 2AA',
                            'country' => 'GB'
                        ],
                        [
                            'email' => 'grace@acme.com',
                            'ip' => '81.2.69.143'
                        ]
                    ],
                    'country' => 'GB',
                    'webhook_url' => 'https://example.com/hooks/spaw'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.create_consistency_bulk_job([
                {
                    'email': 'ada@acme.com',
                    'phone': '+442079460018',
                    'ip': '81.2.69.142',
                    'address': '10 Downing Street, London, SW1A 2AA',
                    'country': 'GB'
                },
                {
                    'email': 'grace@acme.com',
                    'ip': '81.2.69.143'
                }
            ], country='GB', webhook_url='https://example.com/hooks/spaw')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.createConsistencyBulkJob([
                {
                    email: 'ada@acme.com',
                    phone: '+442079460018',
                    ip: '81.2.69.142',
                    address: '10 Downing Street, London, SW1A 2AA',
                    country: 'GB'
                },
                {
                    email: 'grace@acme.com',
                    ip: '81.2.69.143'
                }
            ], {
                country: 'GB',
                webhookUrl: 'https://example.com/hooks/spaw'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->createConsistencyBulkJob([
                [
                    'email' => 'ada@acme.com',
                    'phone' => '+442079460018',
                    'ip' => '81.2.69.142',
                    'address' => '10 Downing Street, London, SW1A 2AA',
                    'country' => 'GB'
                ],
                [
                    'email' => 'grace@acme.com',
                    'ip' => '81.2.69.143'
                ]
            ], country: 'GB', webhookUrl: 'https://example.com/hooks/spaw');
  '/api/v1/consistency/bulk/{jobId}':
    get:
      operationId: get-consistency-bulk-job
      tags:
        - Consistency
      summary: 'Poll a bulk consistency job'
      description: |
        The job's current status and running counts. `status` moves from `queued` to `processing` and ends in `completed`, `failed`, or `cancelled`; `processed` and the three row counts advance as chunks finish. A job that belongs to another account answers `404`.

        `flagged`, `unflagged` and `uncompared` count what the comparison did, and none of them is a verdict about the person a row describes — this product publishes no score, risk level or verdict, and will not. A row is `flagged` when at least one flag was raised on it, `unflagged` when at least two of its signals answered and none of them disagreed, and `uncompared` when fewer than two answered, so there was nothing to compare.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'The job.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        $ref: '#/components/schemas/ConsistencyBulkJob'
              example:
                success: true
                data:
                  job:
                    id: 41
                    status: completed
                    total: 2
                    duplicate_count: 0
                    processed: 2
                    flagged: 1
                    unflagged: 1
                    uncompared: 0
                    credits_used: 5
                    stopped_reason: null
                    cancel_requested: false
                    country: GB
                    webhook_status: delivered
                    webhook_detail: '200'
                    created_at: '2026-09-10T10:12:44+00:00'
                    finished_at: '2026-09-10T10:12:49+00:00'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/consistency/bulk/{jobId} \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/consistency/bulk/{jobId}', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/consistency/bulk/{jobId}',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/consistency/bulk/{jobId}');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.get_consistency_bulk_job(job_id)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.getConsistencyBulkJob(jobId);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->getConsistencyBulkJob($jobId);
  '/api/v1/consistency/bulk/{jobId}/cancel':
    post:
      operationId: cancel-consistency-bulk-job
      tags:
        - Consistency
      summary: 'Cancel a bulk consistency job'
      description: |
        Asks a queued or running job to stop at its next checkpoint. Everything checked so far stays billed and stays in the result file. A job that already finished answers `409 JOB_ALREADY_FINISHED`.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - JOB_ALREADY_FINISHED
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
      responses:
        202:
          description: 'Cancellation was requested.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        $ref: '#/components/schemas/ConsistencyBulkJob'
              example:
                success: true
                data:
                  job:
                    id: 41
                    status: processing
                    total: 20000
                    duplicate_count: 12
                    processed: 4000
                    flagged: 260
                    unflagged: 3600
                    uncompared: 140
                    credits_used: 11200
                    stopped_reason: null
                    cancel_requested: true
                    country: null
                    webhook_status: null
                    webhook_detail: null
                    created_at: '2026-09-10T10:12:44+00:00'
                    finished_at: null
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        409:
          description: 'The job already finished.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: JOB_ALREADY_FINISHED
                  message: 'This job has already finished.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/consistency/bulk/{jobId}/cancel \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/consistency/bulk/{jobId}/cancel', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/consistency/bulk/{jobId}/cancel',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/consistency/bulk/{jobId}/cancel');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.cancel_consistency_bulk_job(job_id)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.cancelConsistencyBulkJob(jobId);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->cancelConsistencyBulkJob($jobId);
  '/api/v1/consistency/bulk/{jobId}/resume':
    post:
      operationId: resume-consistency-bulk-job
      tags:
        - Consistency
      summary: 'Resume a bulk consistency job that ran out of credits'
      description: |
        Runs a job that stopped when the credit balance ran out again, from the top of the list it was given, instead of asking you to upload it a second time. Every leg looked up in the last seven days comes back as a free repeat, so only the work the stopped run never reached is charged — including the legs of the ROW it stopped part-way through, which the result file already carries.

        The job keeps its id, its webhook and its cumulative `credits_used`, which is the real spend on the account. `processed` and the three row counts restart at zero: the part files they were counted from are deleted when a run settles, so the resumed run rebuilds them.

        Only a run whose `stopped_reason` is `insufficient_credits` and whose `status` has settled can be resumed; anything else answers `409 JOB_NOT_RESUMABLE`, and so does a second resume of a run that is already going again. A run whose list has passed out of the retention window answers `409 JOB_INPUT_EXPIRED`. An empty balance answers `402 INSUFFICIENT_CREDITS` rather than queueing a run that would stop again on the first row it never reached.
      x-spaw-auth: bearer
      x-spaw-billing: 'Free to call. The resumed run bills the legs it reaches exactly as the first one did, and anything answered in the last seven days is a free repeat.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - JOB_NOT_RESUMABLE
        - JOB_INPUT_EXPIRED
        - INSUFFICIENT_CREDITS
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
      responses:
        202:
          description: 'The list is queued again.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        $ref: '#/components/schemas/ConsistencyBulkJob'
              example:
                success: true
                data:
                  job:
                    id: 41
                    status: queued
                    total: 20000
                    duplicate_count: 12
                    processed: 0
                    flagged: 0
                    unflagged: 0
                    uncompared: 0
                    credits_used: 11200
                    stopped_reason: null
                    cancel_requested: false
                    country: null
                    webhook_status: null
                    webhook_detail: null
                    created_at: '2026-09-10T10:12:44+00:00'
                    finished_at: null
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        404:
          $ref: '#/components/responses/NotFound'
        409:
          description: 'The run is not in a state a resume can recover (`JOB_NOT_RESUMABLE`), or its list is no longer stored (`JOB_INPUT_EXPIRED`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: JOB_NOT_RESUMABLE
                  message: 'Only a run that stopped because the credit balance ran out can be resumed, and only once it has finished settling.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/consistency/bulk/{jobId}/resume \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/consistency/bulk/{jobId}/resume', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/consistency/bulk/{jobId}/resume',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/consistency/bulk/{jobId}/resume');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.resume_consistency_bulk_job(job_id)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.resumeConsistencyBulkJob(jobId);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->resumeConsistencyBulkJob($jobId);
  '/api/v1/consistency/bulk/{jobId}/webhook/redeliver':
    post:
      operationId: redeliver-consistency-bulk-webhook
      tags:
        - Consistency
      summary: "Send a finished run's completion webhook again"
      description: |
        POSTs a finished run's completion webhook to its `webhook_url` a second time. This is for the case the webhook exists to cover: the run finished, the delivery went out, your endpoint was down for the few minutes it was tried, and the message is gone. `webhook_status` and `webhook_detail` tell you that happened; this is how you ask for it again, instead of falling back to polling — which is the thing the webhook was set up to avoid.

        **The body is the same body.** It is rebuilt from the run's own row rather than replayed from a stored blob, and every field it carries — `status`, `processed`, the row counts, `credits_used`, `stopped_reason`, `finished_at` — is frozen once a run has settled. The bytes are the ones the first delivery carried, so reconciling a redelivery against the original compares equals. (A run that is resumed later settles again and posts a new completion webhook of its own; a redelivery always repeats the most recent one.)

        **The signature is new, and that is correct.** Each delivery is signed as it is sent, so `X-Spaw-Signature-V2` carries a later `t` and a different `v2` digest from the first attempt. That is required rather than incidental: a receiver refuses anything whose `t` is more than five minutes from its own clock, so a signature copied from the original would be refused on arrival. Verify a redelivery exactly as you verify any other delivery. The untimestamped `X-Spaw-Signature` is the HMAC of the body alone, so it *is* identical to the first attempt's, until it stops being sent on 1 March 2027.

        **Nothing deduplicates this for you.** Each call is one real POST, so a receiver that gets both the original and the redelivery sees the event twice. Both bodies carry `job.id`: treat a `bulk_consistency_job.finished` for a job id you have already handled as a repeat and drop it.

        **The delivery is queued, and the answer is `202`.** One attempt is a 10-second timeout retried twice, about half a minute against a receiver that is down — which is the receiver you are asking about. So this call does not wait for it: it answers `202` at once with the run, and `webhook_status` on it reads `pending`, a delivery asked for and not yet answered. Poll the run (`GET /api/v1/consistency/bulk/{jobId}`) until `webhook_status` is `delivered` or `failed`; `webhook_detail` then carries the HTTP status your endpoint gave, or the transport failure when it gave none. A `pending` run has one delivery in flight and asking again queues a second — wait for the first before you decide it failed.

        A run created without a `webhook_url` answers `409 WEBHOOK_NOT_CONFIGURED`, which is checked FIRST because waiting will never fix it; one still queued or running answers `409 JOB_NOT_FINISHED`, because there is no completion to announce yet, and so does a run that stops being finished between the check and the queueing (a resume in another window). A run whose last delivery *succeeded* is not refused — a receiver can lose a message it has already acknowledged, and only you know whether it did.

        A URL that resolves onto a private address is refused at delivery here exactly as on the first attempt, every time, because DNS can be repointed after a URL is saved.
      x-spaw-auth: bearer
      x-spaw-billing: "Free. A redelivery spends no credits and bills nothing. It is capped at 10 a minute counted per signed-in user — neither per key nor per account, so two keys held by one person share the one budget while two teammates have one each, and the dashboard button and the other products' redeliveries all spend the caller's own."
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - WEBHOOK_NOT_CONFIGURED
        - JOB_NOT_FINISHED
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
          example: 41
          description: 'The id returned at creation.'
      responses:
        202:
          description: 'The delivery is queued; `webhook_status` reads `pending` until a worker has an answer.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      job:
                        $ref: '#/components/schemas/ConsistencyBulkJob'
              example:
                success: true
                data:
                  job:
                    id: 41
                    status: completed
                    total: 20000
                    duplicate_count: 12
                    processed: 20000
                    flagged: 2400
                    unflagged: 16100
                    uncompared: 1500
                    credits_used: 56200
                    stopped_reason: null
                    cancel_requested: false
                    country: null
                    webhook_status: pending
                    webhook_detail: null
                    created_at: '2026-09-10T10:12:44+00:00'
                    finished_at: '2026-09-10T11:40:55+00:00'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        409:
          description: 'The run has no webhook URL (`WEBHOOK_NOT_CONFIGURED`), or it has not finished yet (`JOB_NOT_FINISHED`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: WEBHOOK_NOT_CONFIGURED
                  message: 'This run was created without a webhook URL, so there is no completion webhook to send.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          description: 'More than ten redeliveries in a minute from this user, across every product and the dashboard button alike. The shared limit named on other endpoints is not the one that fires here.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: RATE_LIMITED
                  message: 'Too many requests. Retry after the limit resets.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/consistency/bulk/41/webhook/redeliver \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/consistency/bulk/41/webhook/redeliver', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/consistency/bulk/41/webhook/redeliver',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/consistency/bulk/41/webhook/redeliver');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.redeliver_consistency_bulk_webhook(41)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.redeliverConsistencyBulkWebhook(41);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->redeliverConsistencyBulkWebhook(41);
  '/api/v1/consistency/bulk/{jobId}/results':
    get:
      operationId: download-consistency-bulk-results
      tags:
        - Consistency
      summary: "Download a bulk consistency job's results"
      description: |
        The result file as CSV: one row per input row, in input order. A run created from an uploaded file carries that file's own columns first, then the four signals as submitted and the country they were measured against, then the comparison.

        The comparison columns are `signals_requested` and `signals_answered` (`;`-joined, in the order the legs ran), `country_agreement`, `flags` (`;`-joined), one cell per comparison — `phone_address_country`, `ip_address_country`, `ip_phone_country`, `email_ip_disposable_datacenter`, `email_ip_disposable_anonymous`, `email_address_free_provider_mail_drop`, `email_phone_disposable` — and `distance_km`. Last come the few per-product fields those comparisons read, so a difference can be checked against the values that decided it, and `credits_used`.

        **A comparison cell reads `agree`, `differ`, `not_compared`, `missing_value`, `low_confidence`, or nothing at all.** An empty cell means the comparison was never attempted, because the row did not carry both of its signals. The two withheld reasons are written in place of a bare `not_compared` because they mean different things and a spreadsheet has nowhere else to put them: `missing_value` is a field the product did not evaluate — which means "not evaluated" and never "no" — and `low_confidence` is an IP address whose country belongs to the network operator rather than to the person, a VPN exit, a relay or a datacenter egress. `distance_km` is evidence only and never raises a flag: there is no honest distance at which a person stops being themselves.

        `signals_requested` against `signals_answered` is also where a row cut short says so: a run that stopped on an empty balance part-way through a row keeps the legs it had already paid for, and that row answers fewer signals than it asked for.

        Add `?variant=flagged|unflagged|uncompared` to download only the rows a flag was raised on, the rows that were compared and raised none, or the rows too thin to compare; the default `full` returns everything. Available once the job is `completed`, or `cancelled` or `failed` with at least one processed row — those rows were billed, so they stay downloadable. A job still running answers `409 RESULTS_NOT_READY`, a cancelled job with nothing processed `409 JOB_CANCELLED`, and a failed job with nothing processed `409 JOB_FAILED`.

        `format=json` answers the same rows, filtered the same way, as objects keyed by the result file's own header instead of a CSV attachment. It is streamed row by row exactly as the CSV is. A CSV cell carries no type, so only the two the writer encodes come back typed: an empty cell is `null` and `true`/`false` are booleans. Everything else is the string as written, and the columns of an uploaded file come back exactly as they were uploaded.
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - VALIDATION_FAILED
        - RESULTS_NOT_READY
        - JOB_CANCELLED
        - JOB_FAILED
        - RATE_LIMITED
      parameters:
        -
          name: jobId
          in: path
          required: true
          schema:
            type: integer
        -
          name: variant
          in: query
          required: false
          schema:
            type: string
            enum:
              - full
              - flagged
              - unflagged
              - uncompared
          description: 'Restrict the file to the rows a flag was raised on, the rows compared with none, or the rows fewer than two signals answered for. Defaults to full.'
        -
          name: format
          in: query
          required: false
          schema:
            type: string
            enum:
              - csv
              - json
            default: csv
          example: json
          description: '`csv` streams the file as an attachment; `json` streams the same rows as objects.'
      responses:
        200:
          description: 'The result rows, as a CSV attachment or as streamed JSON.'
          content:
            text/csv:
              schema:
                type: string
              example: |
                email,phone,ip,address,country,signals_requested,signals_answered,country_agreement,flags,phone_address_country,ip_address_country,ip_phone_country,email_ip_disposable_datacenter,email_ip_disposable_anonymous,email_address_free_provider_mail_drop,email_phone_disposable,distance_km,email_deliverable,email_disposable,email_free_provider,phone_valid,phone_country,phone_is_disposable,ip_country,ip_risk_level,ip_is_datacenter,ip_is_anonymous,address_valid,address_country,address_is_mail_drop,credits_used
                ada@acme.com,+442079460018,81.2.69.142,"10 Downing Street, London, SW1A 2AA",GB,ip;phone;address;email,ip;phone;address;email,agree,,agree,agree,agree,not_compared,not_compared,not_compared,not_compared,12.4,true,false,false,true,GB,false,GB,low,false,false,true,GB,false,3
            application/json:
              schema:
                $ref: '#/components/schemas/BulkResultRows'
              example:
                success: true
                data:
                  job:
                    id: 41
                    status: completed
                  results:
                    -
                      email: ada@acme.com
                      phone: '+442079460018'
                      ip: 81.2.69.142
                      address: '10 Downing Street, London, SW1A 2AA'
                      country: GB
                      signals_requested: ip;phone;address;email
                      signals_answered: ip;phone;address;email
                      country_agreement: agree
                      flags: null
                      ip_address_country: agree
                      distance_km: '12.4'
                      credits_used: '3'
                meta:
                  variant: full
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        409:
          description: 'The job is still running (`RESULTS_NOT_READY`), or was cancelled or failed before any row was processed (`JOB_CANCELLED`, `JOB_FAILED`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                success: false
                error:
                  code: RESULTS_NOT_READY
                  message: 'The results are not ready yet.'
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/consistency/bulk/{jobId}/results \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/consistency/bulk/{jobId}/results', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/consistency/bulk/{jobId}/results',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/consistency/bulk/{jobId}/results');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.download_consistency_bulk_results(job_id)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.downloadConsistencyBulkResults(jobId);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->downloadConsistencyBulkResults($jobId);
  /api/v1/entity:
    post:
      operationId: verify-entity
      tags:
        - Entity
      summary: 'Check a business identifier against the registers'
      description: |
        Reads a business identifier against the registers this server holds: a Legal Entity Identifier against the GLEIF Level 1 golden copy, or a UK company number against the Companies House free company data product. Both are bulk files synced locally, so no third party sits in the request path and nothing about the lookup leaves the server.

        Send `identifier` with a `type` of `lei` or `company_number`. A company number needs the `country` whose register it belongs to, because company numbers are only unique inside their own register; `GB` is the register installed today. A LEI needs no country: the identifier is global by construction. A UK company number is zero-padded to eight characters before it is looked up, so `1234567` and `01234567` are the same company.

        The answer gives the entity's legal name, whether it is still live (`status`, with the register's own wording in `status_detail`), the country and jurisdiction it was formed in, its category and legal form, and when it was registered. For a LEI it adds `checksum_valid` — the ISO 7064 MOD 97-10 check digit defined by ISO 17442, which catches a mistyped identifier and says nothing about whether it was ever issued — plus `registration_status` and `next_renewal_at`, because a LEI registration can lapse while the entity behind it is perfectly alive.

        Where a LEI names its entry in a national register this server also holds, the two records are compared field by field. `agreements` quotes both sides of each comparison and `flags` names what differs: `name_differs_across_registers`, `status_differs_across_registers`, `dissolved_in_national_register`, `lei_registration_not_current`. Names are compared on their letters and digits with the company-form words removed, so only a real difference is reported. Where there is no counterpart, `agreements` is empty — an absent register is not a disagreement.

        `sources` names every register behind the answer with the date of the copy. That matters here: the Companies House snapshot is republished monthly and can be up to five weeks behind the live register, while the GLEIF golden copy is published daily.

        The product answers about organisations, not people. No registered address is stored or returned. A registered address is very often somebody's home, and an identifier alone does not say whether a company or a sole trader stands behind it, so the product holds none of them rather than some of them badly.
      x-spaw-auth: bearer
      x-spaw-billing: '1 credit when a register answers. A malformed identifier, one no register carries, an unsupported register and a register that is not installed are all free, and a 7-day repeat of the same identifier on the same account is free.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - INSUFFICIENT_CREDITS
        - RATE_LIMITED
        - KEY_SCOPE_DENIED
        - EMAIL_NOT_VERIFIED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - identifier
                - type
              properties:
                identifier:
                  type: string
                  maxLength: 64
                  description: 'The Legal Entity Identifier or company number to check.'
                type:
                  type: string
                  enum:
                    - lei
                    - company_number
                  description: 'Which register the identifier belongs to.'
                country:
                  type:
                    - string
                    - 'null'
                  maxLength: 2
                  description: 'The ISO 3166-1 alpha-2 country of the company register. Required when `type` is `company_number`; ignored for a LEI.'
            example:
              identifier: 213800QILIUD4ROSUO03
              type: lei
      responses:
        200:
          description: 'The entity as the registers hold it; `found: false` with a `reason` for an identifier none of them could answer.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/EntityResult'
                  meta:
                    $ref: '#/components/schemas/Meta'
              example:
                success: true
                data:
                  identifier: 213800QILIUD4ROSUO03
                  identifier_type: lei
                  found: true
                  reason: null
                  name: 'ACME HOLDINGS LIMITED'
                  status: active
                  status_detail: ACTIVE
                  country: GB
                  jurisdiction: GB
                  category: GENERAL
                  legal_form: H0PO
                  registered_at: '2016-04-21'
                  record_updated_at: '2026-08-14'
                  checksum_valid: true
                  registration_status: ISSUED
                  next_renewal_at: '2027-04-21'
                  other_register: uk-companies-house
                  other_identifier: '01234567'
                  flags: []
                  agreements:
                    -
                      field: name
                      lei: 'ACME HOLDINGS LIMITED'
                      national_register: 'ACME HOLDINGS LIMITED'
                      result: agree
                    -
                      field: status
                      lei: active
                      national_register: active
                      result: agree
                  sources:
                    -
                      dataset: gleif-lei-level-1
                      version: '2026-09-08'
                    -
                      dataset: uk-companies-house-entities
                      version: '2026-09-01'
                meta:
                  credits_used: 1
                  credits_remaining: 999
                  cache_hit: false
                  request_id: req_01m1kgdm4xngzmbmff68g94w0c
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/entity \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "identifier": "213800QILIUD4ROSUO03",
              "type": "lei"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/entity', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "identifier": "213800QILIUD4ROSUO03",
                "type": "lei"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/entity',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'identifier': '213800QILIUD4ROSUO03',
                    'type': 'lei'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/entity');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'identifier' => '213800QILIUD4ROSUO03',
                    'type' => 'lei'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.verify_entity('213800QILIUD4ROSUO03', type='lei')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.verifyEntity('213800QILIUD4ROSUO03', {
                type: 'lei'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->verifyEntity('213800QILIUD4ROSUO03', type: 'lei');
  /api/v1/entity/batch:
    post:
      operationId: lookup-entity-batch
      tags:
        - Entity
      summary: 'Check up to 500 identifiers in one call'
      description: |
        Runs the single-identifier lookup for every item, in input order, under the exact single-lookup billing rules. Every answer is read from registers synced onto this server, so a full list makes no network call at all. `data.results[]` mirrors the single-identifier response per item and each item's `meta` carries `credits_used` and `cache_hit`.

        An item is either a bare identifier string, which is read as a LEI because that is the only type needing no country, or an object with `identifier`, `type` and — for a company number — the `country` whose register it belongs to. Countries are only required per item, never for the list, because a list may mix registers.

        The cap is 500, not the 1,000 the IP and phone batches take: those read indexes already held in memory, while each identifier here is a database read, and two when a LEI names a national-register counterpart. It is the same limit a monitored list of identifiers has, and a monitor run walks its list through this same lookup.

        `meta.found` is the count this endpoint has that the other batches do not need. Business lookups bill only for an identifier a register actually carried, so a list of 500 unknown identifiers is processed in full and billed nothing, and `processed` on its own would imply a charge that was never made. `credits_used` is `found` less any identifier that was already looked up on this account inside the last seven days.

        The list is walked in order and stops where the balance would run out: running out of credits mid-batch returns the paid partial results with `meta.stopped_reason: "insufficient_credits"` and `processed` lower than `requested`, never discarding billed work. Only a batch whose first lookup is refused answers the typed `402`. A key with a daily credit cap stops the list the same way once the cap is spent, reporting `meta.stopped_reason: "key_spend_cap"`; a call that starts with the cap already spent answers `429 KEY_SPEND_CAP_REACHED` and runs nothing.

        A batch counts as one request against the rate limit. As with the single lookup, no registered address is stored or returned.

        Every item carries `index`, its zero-based position in the list you sent, and `input`, the identifier exactly as you sent it — the answer carries the register's own spelling of it. A batch stopped early by `stopped_reason` answers only a prefix of the list, so those two are what line an answer up with the row it came from.
      x-spaw-auth: bearer
      x-spaw-billing: 'Each identifier bills like a single lookup — 1 credit only when a register carried it, free for an unknown, malformed or unsupported identifier, free for a register that is not installed and free for a 7-day repeat — and the call stops cleanly where the balance ends.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - INSUFFICIENT_CREDITS
        - KEY_SPEND_CAP_REACHED
        - RATE_LIMITED
        - KEY_SCOPE_DENIED
        - EMAIL_NOT_VERIFIED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - identifiers
              properties:
                identifiers:
                  type: array
                  minItems: 1
                  maxItems: 500
                  description: '1 to 500 identifiers. An item is a LEI string, or an object carrying its own type and country.'
                  items:
                    type:
                      - string
                      - object
                    properties:
                      identifier:
                        type: string
                        maxLength: 64
                      type:
                        type: string
                        enum:
                          - lei
                          - company_number
                      country:
                        type:
                          - string
                          - 'null'
                        maxLength: 2
                        description: 'Required when `type` is `company_number`.'
            example:
              identifiers:
                - 213800QILIUD4ROSUO03
                -
                  identifier: '01234567'
                  type: company_number
                  country: GB
      responses:
        200:
          description: 'One result per processed identifier, plus the batch totals.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EntityBatchResponse'
              example:
                success: true
                data:
                  results:
                    -
                      index: 0
                      input: 213800QILIUD4ROSUO03
                      data:
                        identifier: 213800QILIUD4ROSUO03
                        identifier_type: lei
                        found: true
                        reason: null
                        name: 'ACME HOLDINGS LIMITED'
                        status: active
                        country: GB
                        checksum_valid: true
                        registration_status: ISSUED
                        flags: []
                      meta:
                        credits_used: 1
                        cache_hit: false
                    -
                      index: 1
                      input: '01234567'
                      data:
                        identifier: '01234567'
                        identifier_type: company_number
                        found: true
                        reason: null
                        name: 'ACME HOLDINGS LIMITED'
                        status: dissolved
                        status_detail: Dissolved
                        country: GB
                        flags:
                          - dissolved_in_national_register
                      meta:
                        credits_used: 1
                        cache_hit: false
                    -
                      index: 2
                      input: 5493001KJTIIGC8Y1R99
                      data:
                        identifier: 5493001KJTIIGC8Y1R99
                        identifier_type: lei
                        found: false
                        reason: unknown_identifier
                        name: null
                        checksum_valid: false
                        flags: []
                      meta:
                        credits_used: 0
                        cache_hit: false
                meta:
                  requested: 3
                  processed: 3
                  found: 2
                  credits_used: 2
                  credits_remaining: 998
                  stopped_reason: null
                  request_id: req_01m1kgdrtqdvwnks99vfgx2rcw
        401:
          $ref: '#/components/responses/Unauthenticated'
        402:
          $ref: '#/components/responses/InsufficientCredits'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/entity/batch \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "identifiers": [
                "213800QILIUD4ROSUO03",
                {
                  "identifier": "01234567",
                  "type": "company_number",
                  "country": "GB"
                }
              ]
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/entity/batch', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "identifiers": [
                  "213800QILIUD4ROSUO03",
                  {
                    "identifier": "01234567",
                    "type": "company_number",
                    "country": "GB"
                  }
                ]
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/entity/batch',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'identifiers': [
                        '213800QILIUD4ROSUO03',
                        {
                            'identifier': '01234567',
                            'type': 'company_number',
                            'country': 'GB'
                        }
                    ]
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/entity/batch');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'identifiers' => [
                        '213800QILIUD4ROSUO03',
                        [
                            'identifier' => '01234567',
                            'type' => 'company_number',
                            'country' => 'GB'
                        ]
                    ]
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.verify_entities([
                '213800QILIUD4ROSUO03',
                {
                    'identifier': '01234567',
                    'type': 'company_number',
                    'country': 'GB'
                }
            ])
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.verifyEntities([
                '213800QILIUD4ROSUO03',
                {
                    identifier: '01234567',
                    type: 'company_number',
                    country: 'GB'
                }
            ]);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->verifyEntities([
                '213800QILIUD4ROSUO03',
                [
                    'identifier' => '01234567',
                    'type' => 'company_number',
                    'country' => 'GB'
                ]
            ]);
  /api/v1/entity/monitors:
    get:
      operationId: list-entity-monitors
      tags:
        - Entity
      summary: 'List business monitors'
      description: |
        Every saved list of business identifiers on the account, newest first, 100 to a page. Each carries the summary of its last run, including `stopped_reason` when it did not finish cleanly: `insufficient_credits` or `key_spend_cap` where the run stopped part-way, `run_failed` where it died outright, and `run_failed_retrying` where it died and the schedule has been pulled in to check again — `retry_at` in the same block says when that becomes due, and the hourly sweep picks it up at or after that, so between 45 minutes and about an hour and three quarters later. A summary carrying a `stopped_reason` of either failure kind has NO counts in it at all: the run produced none, so read a missing count as unknown rather than as zero.

        `meta.retention_days` says how long a list nobody runs is kept. A monitor in active use is never pruned — each run advances `last_run_at` — but one that has stopped being run is deleted along with the identifiers it stored, because an identifier can belong to a sole trader as easily as to a company and nothing in it says which.
      x-spaw-auth: bearer
      x-spaw-billing: 'Free. Listing monitors costs nothing; only their runs are billed.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - RATE_LIMITED
        - KEY_SCOPE_DENIED
        - EMAIL_NOT_VERIFIED
      responses:
        200:
          description: "The account's business monitors."
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitors:
                        type: array
                        items:
                          $ref: '#/components/schemas/EntityMonitor'
                  meta:
                    type: object
                    properties:
                      page:
                        type: integer
                      per_page:
                        type: integer
                      total:
                        type: integer
                      retention_days:
                        type: integer
                        description: 'How long a list nobody runs is kept before it is deleted.'
        401:
          $ref: '#/components/responses/Unauthenticated'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/entity/monitors \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/entity/monitors', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/entity/monitors',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/entity/monitors');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.list_entity_monitors()
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.listEntityMonitors();
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->listEntityMonitors();
    post:
      operationId: create-entity-monitor
      tags:
        - Entity
      summary: 'Save a business monitor'
      description: |
        Save up to 500 business identifiers to be re-checked weekly or monthly. There is no daily cadence: the UK company file is republished monthly, so a daily run would bill for data that cannot have changed.

        Each entry is an object of `identifier`, `type` (`lei` or `company_number`) and, for a company number, the `country` of its register. A bare string is read as a LEI, because that is the only identifier that needs no country.

        The first run happens immediately and only sets the baseline — alerts start with the next one, so saving a list never emails you about what was already true when you saved it.

        An email goes out when an entry that was fine last time is not any more: the national register now records it as dissolved, the register marks it inactive, its LEI registration has lapsed, or the two registers have stopped agreeing about it. An identifier no register could answer for is counted as `unverifiable` and never alerted on — that is a limit of the data here, not a fact about the company.

        With a `webhook_url`, a finished run is POSTed there as `{ "event": "entity_monitor.run", "monitor": { "id", "name", "cadence", "last_run_at", "next_run_at" }, "summary": { … the same block as `last_summary` … }, "changes": [ … ] }`, where `changes` names the identifiers the alert email names. The `X-Spaw-Signature-V2` header carries `t={unix seconds},v2={lowercase hex}`, where the hex is the HMAC-SHA256 of `v2:{t}:{raw body}` keyed with `webhook_secret`, which this response returns once; a receiver should refuse anything whose `t` is more than five minutes from its own clock, since a signature over the body alone would let a captured delivery be replayed at it forever. The original `X-Spaw-Signature`, that HMAC of the body alone, is sent beside it until 1 March 2027. Connection errors and 5xx answers are retried twice with a short backoff, three attempts in all, and the outcome is readable afterwards as `webhook_status` and `webhook_detail`. A URL that resolves onto a private network is never posted to.
      x-spaw-auth: bearer
      x-spaw-billing: 'Each run bills each identifier exactly as a single lookup: 1 credit when a register answers, free when none does, and free for a 7-day repeat — so a weekly cadence over a monthly register costs almost nothing after the first run. A run that exhausts the balance stops and reports `stopped_reason`.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
        - KEY_SCOPE_DENIED
        - EMAIL_NOT_VERIFIED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
                - identifiers
                - cadence
              properties:
                name:
                  type: string
                  maxLength: 100
                identifiers:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type: object
                    required:
                      - identifier
                    properties:
                      identifier:
                        type: string
                        maxLength: 64
                      type:
                        type: string
                        enum:
                          - lei
                          - company_number
                        default: lei
                      country:
                        type:
                          - string
                          - 'null'
                        maxLength: 2
                        description: 'Required when type is company_number.'
                cadence:
                  type: string
                  enum:
                    - weekly
                    - monthly
                webhook_url:
                  type:
                    - string
                    - 'null'
                  maxLength: 2048
                  format: uri
                  description: 'An https URL every reported run is POSTed to. The signing secret is returned once, in this response, and never again.'
                webhook_events:
                  type: string
                  enum:
                    - changes
                    - every_run
                  default: changes
                  description: 'When to post. `changes` posts only when the run found something new — decay, a status change, a newly flagged entry — or stopped early; `every_run` posts after every run, which also says the monitor is still running.'
            example:
              name: 'Key suppliers'
              identifiers:
                -
                  identifier: 213800QILIUD4ROSUO03
                  type: lei
                -
                  identifier: '01234567'
                  type: company_number
                  country: GB
              cadence: monthly
      responses:
        201:
          description: 'The saved monitor. Its first run is already queued.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        allOf:
                          -
                            $ref: '#/components/schemas/EntityMonitor'
                          -
                            type: object
                            properties:
                              webhook_secret:
                                type:
                                  - string
                                  - 'null'
                                description: 'The webhook signing secret, shown once here and never readable again. Null when the monitor has no webhook.'
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/entity/monitors \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "name": "Key suppliers",
              "identifiers": [
                {
                  "identifier": "213800QILIUD4ROSUO03",
                  "type": "lei"
                },
                {
                  "identifier": "01234567",
                  "type": "company_number",
                  "country": "GB"
                }
              ],
              "cadence": "monthly"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/entity/monitors', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "name": "Key suppliers",
                "identifiers": [
                  {
                    "identifier": "213800QILIUD4ROSUO03",
                    "type": "lei"
                  },
                  {
                    "identifier": "01234567",
                    "type": "company_number",
                    "country": "GB"
                  }
                ],
                "cadence": "monthly"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/entity/monitors',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'name': 'Key suppliers',
                    'identifiers': [
                        {
                            'identifier': '213800QILIUD4ROSUO03',
                            'type': 'lei'
                        },
                        {
                            'identifier': '01234567',
                            'type': 'company_number',
                            'country': 'GB'
                        }
                    ],
                    'cadence': 'monthly'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/entity/monitors');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'name' => 'Key suppliers',
                    'identifiers' => [
                        [
                            'identifier' => '213800QILIUD4ROSUO03',
                            'type' => 'lei'
                        ],
                        [
                            'identifier' => '01234567',
                            'type' => 'company_number',
                            'country' => 'GB'
                        ]
                    ],
                    'cadence' => 'monthly'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.create_entity_monitor(name='Key suppliers', identifiers=[
                {
                    'identifier': '213800QILIUD4ROSUO03',
                    'type': 'lei'
                },
                {
                    'identifier': '01234567',
                    'type': 'company_number',
                    'country': 'GB'
                }
            ], cadence='monthly')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.createEntityMonitor({
                name: 'Key suppliers',
                identifiers: [
                    {
                        identifier: '213800QILIUD4ROSUO03',
                        type: 'lei'
                    },
                    {
                        identifier: '01234567',
                        type: 'company_number',
                        country: 'GB'
                    }
                ],
                cadence: 'monthly'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->createEntityMonitor(name: 'Key suppliers', identifiers: [
                [
                    'identifier' => '213800QILIUD4ROSUO03',
                    'type' => 'lei'
                ],
                [
                    'identifier' => '01234567',
                    'type' => 'company_number',
                    'country' => 'GB'
                ]
            ], cadence: 'monthly');
  '/api/v1/entity/monitors/{monitorId}':
    get:
      operationId: get-entity-monitor
      tags:
        - Entity
      summary: 'Get a business monitor'
      description: 'One monitor with the status of every identifier from its last run.'
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
        - KEY_SCOPE_DENIED
        - EMAIL_NOT_VERIFIED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'The monitor and its per-identifier results.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        allOf:
                          -
                            $ref: '#/components/schemas/EntityMonitor'
                          -
                            type: object
                            properties:
                              results:
                                type:
                                  - object
                                  - 'null'
                                description: 'Each identifier mapped to its status from the last run.'
                                additionalProperties:
                                  $ref: '#/components/schemas/EntityMonitorResult'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl https://spaw.co/api/v1/entity/monitors/{monitorId} \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/entity/monitors/{monitorId}', {
              method: 'GET',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                'https://spaw.co/api/v1/entity/monitors/{monitorId}',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/entity/monitors/{monitorId}');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.get_entity_monitor(monitor_id)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.getEntityMonitor(monitorId);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->getEntityMonitor($monitorId);
    patch:
      operationId: update-entity-monitor
      tags:
        - Entity
      summary: 'Change a monitor'
      description: |
        Changes a saved monitor in place: rename it, move it to another cadence, replace the identifiers, or point it at a webhook. Send only the fields you are changing; a body that names none of them answers `422` rather than quietly changing nothing, because that is the shape a misspelled field name takes.

        Replacing `identifiers` replaces the list. What the last run recorded about the identifiers that stayed is kept, so their next run still compares against the answer they already had; what it recorded about the ones that are gone is deleted with them; and an entry that is new starts with no baseline, so its first run only sets one. A rename never touches a stored answer — they are keyed by the entry, never by the list.

        Changing `cadence` neither skips a run nor starts a second one: a run already scheduled sooner than the new interval still happens, and a longer interval applies from the one after it. Nothing is queued by this call — the change applies to the next run, and `POST .../run` is there to have it now.

        Sending a `webhook_url` that differs from the one stored mints a new signing secret and returns it once, here; sending the URL it already has keeps the secret it is signing with, and `null` removes the webhook. A monitor that belongs to another account answers `404`.
      x-spaw-auth: bearer
      x-spaw-billing: 'Free. The change applies to the next run.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - VALIDATION_FAILED
        - RATE_LIMITED
        - KEY_SCOPE_DENIED
        - EMAIL_NOT_VERIFIED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
          example: 41
          description: "The monitor's id from the list endpoint."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                name:
                  type: string
                  maxLength: 100
                identifiers:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type:
                      - string
                      - object
                cadence:
                  type: string
                  enum:
                    - weekly
                    - monthly
                webhook_url:
                  type:
                    - string
                    - 'null'
                  maxLength: 2048
                  format: uri
                webhook_events:
                  type: string
                  enum:
                    - changes
                    - every_run
            example:
              name: 'Key suppliers (2026)'
              cadence: weekly
      responses:
        200:
          description: 'The monitor as it now stands.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        allOf:
                          -
                            $ref: '#/components/schemas/EntityMonitor'
                          -
                            type: object
                            properties:
                              webhook_secret:
                                type: string
                                description: 'Present only when this call pointed the monitor at a URL it was not already posting to. Shown once.'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X PATCH https://spaw.co/api/v1/entity/monitors/41 \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "name": "Key suppliers (2026)",
              "cadence": "weekly"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/entity/monitors/41', {
              method: 'PATCH',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "name": "Key suppliers (2026)",
                "cadence": "weekly"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.patch(
                'https://spaw.co/api/v1/entity/monitors/41',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'name': 'Key suppliers (2026)',
                    'cadence': 'weekly'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/entity/monitors/41');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'PATCH',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'name' => 'Key suppliers (2026)',
                    'cadence' => 'weekly'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.update_entity_monitor(41, name='Key suppliers (2026)', cadence='weekly')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.updateEntityMonitor(41, {
                name: 'Key suppliers (2026)',
                cadence: 'weekly'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->updateEntityMonitor(41, [
                'name' => 'Key suppliers (2026)',
                'cadence' => 'weekly'
            ]);
    delete:
      operationId: delete-entity-monitor
      tags:
        - Entity
      summary: 'Delete a business monitor'
      description: 'Stop monitoring the list. The identifiers and results it stored are deleted with it.'
      x-spaw-auth: bearer
      x-spaw-billing: Free.
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
        - KEY_SCOPE_DENIED
        - EMAIL_NOT_VERIFIED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'The monitor is gone.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X DELETE https://spaw.co/api/v1/entity/monitors/{monitorId} \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/entity/monitors/{monitorId}', {
              method: 'DELETE',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.delete(
                'https://spaw.co/api/v1/entity/monitors/{monitorId}',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/entity/monitors/{monitorId}');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'DELETE',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.delete_entity_monitor(monitor_id)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.deleteEntityMonitor(monitorId);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->deleteEntityMonitor($monitorId);
  '/api/v1/entity/monitors/{monitorId}/run':
    post:
      operationId: run-entity-monitor
      tags:
        - Entity
      summary: 'Run a business monitor now'
      description: 'Re-check the list now instead of waiting for the schedule. The run is queued and answers 202; poll the monitor for its results.'
      x-spaw-auth: bearer
      x-spaw-billing: 'Billed per identifier exactly like a single lookup, with 7-day repeats free.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - NOT_FOUND
        - RATE_LIMITED
        - KEY_SCOPE_DENIED
        - EMAIL_NOT_VERIFIED
      parameters:
        -
          name: monitorId
          in: path
          required: true
          schema:
            type: integer
      responses:
        202:
          description: 'The run is queued.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      monitor:
                        $ref: '#/components/schemas/EntityMonitor'
        401:
          $ref: '#/components/responses/Unauthenticated'
        404:
          $ref: '#/components/responses/NotFound'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/entity/monitors/{monitorId}/run \
              -H "Authorization: Bearer sk_live_…"
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/entity/monitors/{monitorId}/run', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
              },
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/entity/monitors/{monitorId}/run',
                headers={'Authorization': 'Bearer sk_live_…'},
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/entity/monitors/{monitorId}/run');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…'],
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.run_entity_monitor(monitor_id)
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.runEntityMonitor(monitorId);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->runEntityMonitor($monitorId);
  /api/v1/webhooks/test:
    post:
      operationId: send-test-webhook
      tags:
        - Webhooks
      summary: 'Send a test webhook'
      description: |
        Posts one sample payload to a URL you name, signed exactly as a real delivery is, and answers with the secret it signed with, both signatures it sent, and the exact bytes all of them were computed over. It is how a signature check gets written and confirmed before a paid run depends on it: until this endpoint existed, the first signed request you ever saw arrived when a bulk job you had queued and paid for happened to finish.

        `event` is one of the names this API documents: `bulk_email_job.finished`, `bulk_phone_job.finished`, `bulk_ip_job.finished`, `bulk_address_job.finished`, `bulk_consistency_job.finished`, `email.settled`, `email_monitor.run`, `phone_monitor.run`, `ip_monitor.run`, `address_monitor.run` and `entity_monitor.run`. The body has the shape that event really carries, with fixed sample values and one addition: `test` is `true`. Where an event wraps a whole lookup result — `email.settled` does — the sample carries a handful of that result's fields rather than all of them, because what a signature check needs to see is the envelope and the exact bytes, and a full result copied into this document would be one more thing to drift. That field is there because a receiver that cannot compute an HMAC — most low-code webhook triggers cannot — still has to be able to tell a test delivery from a real one. Nothing else about the shape differs, and nothing in the body comes from your request.

        The delivery runs through the same signer every other webhook on the platform runs through, so it behaves the same way: HTTPS only, both signature headers — the timestamped `X-Spaw-Signature-V2` (`t={unix seconds},v2={lowercase hex}`, the HMAC-SHA256 of `v2:{t}:{raw body}`) and the original `X-Spaw-Signature` over the body alone, which is sent until 1 March 2027 — a ten-second timeout, three attempts on a connection error or a 5xx answer, and no redirect followed — a `3xx` answer is reported as the failure it is, because a redirected POST arrives as a GET with no body. A URL whose host resolves onto a private or reserved address is never posted to at all: `delivered` is false and `detail` reads `Refused: private address`.

        `status_code` is what your endpoint answered, or `null` when it answered nothing. What it answered in its own body is never read and never returned. The `secret` is minted for this one call and stored nowhere — it is not, and never will be, a real job's or monitor's signing secret, which is shown once at creation and never again.
      x-spaw-auth: bearer
      x-spaw-billing: 'Free. A test delivery costs no credits and is not a lookup. Limited to 10 requests a minute for the key: it is a debugging tool, not a way to generate traffic.'
      x-spaw-errors:
        - UNAUTHENTICATED
        - VALIDATION_FAILED
        - RATE_LIMITED
        - EMAIL_NOT_VERIFIED
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - url
                - event
              properties:
                url:
                  type: string
                  format: uri
                  maxLength: 2048
                  description: 'The HTTPS URL to post to. Plain http is refused, as it is everywhere a webhook URL is accepted.'
                event:
                  type: string
                  enum:
                    - bulk_email_job.finished
                    - bulk_phone_job.finished
                    - bulk_ip_job.finished
                    - bulk_address_job.finished
                    - bulk_consistency_job.finished
                    - email.settled
                    - email_monitor.run
                    - phone_monitor.run
                    - ip_monitor.run
                    - address_monitor.run
                    - entity_monitor.run
                  description: 'Which documented payload to send.'
            example:
              url: 'https://hooks.example.com/spaw'
              event: bulk_email_job.finished
      responses:
        200:
          description: 'The delivery was attempted, and this is everything about it.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      delivered:
                        type: boolean
                        description: 'True when your endpoint answered 2xx.'
                      status_code:
                        type:
                          - integer
                          - 'null'
                        description: 'The HTTP status your endpoint answered with, or null when it answered nothing.'
                      detail:
                        type:
                          - string
                          - 'null'
                        description: '`HTTP <status>`, the transport failure when there was no answer, or `Refused: private address`. The same field a bulk run and a monitor store as `webhook_detail`.'
                      secret:
                        type: string
                        description: "The signing key for this test only. Stored nowhere, and never a real job's or monitor's secret."
                      signature:
                        type: string
                        description: 'The lowercase hex HMAC-SHA256 of `body` alone: the value of the older X-Spaw-Signature header, which is what a check written against that header has to arrive at. It is sent as a header until 1 March 2027 and is no longer the one to write against.'
                      timestamped_signature:
                        type: string
                        description: 'The value of X-Spaw-Signature-V2 on this delivery, whole: `t={unix seconds},v2={lowercase hex}`, where the hex is the HMAC-SHA256 of `v2:{t}:{body}`. This is the header every delivery carries after 1 March 2027, so it is the one to check against.'
                      timestamp:
                        type: integer
                        description: 'The second the payload was stamped with, which is the `t` inside `timestamped_signature`, repeated here so you can hash `v2:{timestamp}:{body}` without parsing the header apart. A real receiver reads it out of the header it was sent.'
                      body:
                        type: string
                        description: 'The exact bytes that were signed and posted. Verify against these, not against a re-encoded copy.'
              example:
                success: true
                data:
                  delivered: true
                  status_code: 200
                  detail: 'HTTP 200'
                  secret: 3nQ8Zk2xVb7Lp1sTfY6wR4mJcH0aD5gU9eKqNvXz
                  signature: 45f6705121de705be218247b0f31a49d895c46d2e9f9d38d150bec2f08b629b9
                  timestamped_signature: 't=1757502000,v2=6a0e5f0f6bd0ba9a4bd6c9bbd0c0b5b6b1a1d0f04a2b9a02de3b1a3d0c4b6f21'
                  timestamp: 1757502000
                  body: '{"test":true,"event":"bulk_email_job.finished","job":{"id":512,"status":"completed","total":2,"processed":2,"deliverable":1,"risky":0,"undeliverable":1,"credits_used":1,"stopped_reason":null,"finished_at":"2026-09-03T10:14:02+00:00"}}'
        401:
          $ref: '#/components/responses/Unauthenticated'
        422:
          $ref: '#/components/responses/ValidationFailed'
        429:
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        -
          lang: Shell
          label: curl
          source: |-
            curl -X POST https://spaw.co/api/v1/webhooks/test \
              -H "Authorization: Bearer sk_live_…" \
              -H "Content-Type: application/json" \
              -d '{
              "url": "https://hooks.example.com/spaw",
              "event": "bulk_email_job.finished"
            }'
        -
          lang: JavaScript
          label: JavaScript
          source: |-
            const response = await fetch('https://spaw.co/api/v1/webhooks/test', {
              method: 'POST',
              headers: {
                'Authorization': 'Bearer sk_live_…',
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                "url": "https://hooks.example.com/spaw",
                "event": "bulk_email_job.finished"
              }),
            });
            const result = await response.json();
        -
          lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                'https://spaw.co/api/v1/webhooks/test',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'url': 'https://hooks.example.com/spaw',
                    'event': 'bulk_email_job.finished'
                },
            )
            result = response.json()
        -
          lang: PHP
          label: PHP
          source: |-
            $ch = curl_init('https://spaw.co/api/v1/webhooks/test');
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CUSTOMREQUEST => 'POST',
                CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode([
                    'url' => 'https://hooks.example.com/spaw',
                    'event' => 'bulk_email_job.finished'
                ]),
            ]);
            $result = json_decode(curl_exec($ch), true);
        -
          lang: Python
          label: 'Python SDK'
          source: |-
            from spaw import Client

            client = Client('sk_live_…')
            result = client.send_test_webhook('https://hooks.example.com/spaw', event='bulk_email_job.finished')
        -
          lang: JavaScript
          label: 'Node SDK'
          source: |-
            import Spaw from 'spaw';

            const spaw = new Spaw({ apiKey: 'sk_live_…' });
            const result = await spaw.sendTestWebhook('https://hooks.example.com/spaw', {
                event: 'bulk_email_job.finished'
            });
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->sendTestWebhook('https://hooks.example.com/spaw', event: 'bulk_email_job.finished');
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: 'sk_live_ API key'
      description: "A secret API key from the dashboard's API keys page, sent as a bearer token."
  responses:
    Unauthenticated:
      description: 'The key is missing, malformed, or revoked.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            success: false
            error:
              code: UNAUTHENTICATED
              message: 'Provide a valid API key as a bearer token.'
              request_id: req_01m1kgdm4xngzmbmff68g94w0c
    ValidationFailed:
      description: 'The request body could not be validated; `error.errors` lists the fields.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            success: false
            error:
              code: VALIDATION_FAILED
              message: 'The email field is required.'
              errors:
                email:
                  - 'The email field is required.'
              request_id: req_01m1kgdm4xngzmbmff68g94w0c
    InsufficientCredits:
      description: 'The balance is empty. The lookup did not run.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            success: false
            error:
              code: INSUFFICIENT_CREDITS
              message: 'Your credit balance is empty.'
              request_id: req_01m1kgdm4xngzmbmff68g94w0c
    RateLimited:
      description: 'Over 5 requests per second for the key. Retry after the limit resets.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            success: false
            error:
              code: RATE_LIMITED
              message: 'Too many requests. Retry after the limit resets.'
              request_id: req_01m1kgdm4xngzmbmff68g94w0c
    NotFound:
      description: 'No such record on this account.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            success: false
            error:
              code: NOT_FOUND
              message: 'No record with that id on this account.'
              request_id: req_01m1kgdm4xngzmbmff68g94w0c
  schemas:
    IpFeedbackItem:
      type: object
      required:
        - ip
        - outcome
      properties:
        ip:
          type: string
          maxLength: 64
          description: 'An IPv4 or IPv6 address, in any common notation; IPv4-mapped IPv6 collapses to dotted-quad.'
        outcome:
          type: string
          enum:
            - abuse
            - fraud
            - bot
            - chargeback
        occurred_at:
          type:
            - string
            - 'null'
          format: date-time
          description: 'When it happened. Defaults to now. A time more than five minutes in the future is refused; one inside that margin is recorded as now.'
    IpFeedbackSummary:
      type: object
      properties:
        window_days:
          type: integer
        total:
          type: integer
        addresses:
          type: integer
          description: 'Distinct addresses reported on inside the window.'
        outcomes:
          type: object
          properties:
            abuse:
              type: integer
            fraud:
              type: integer
            bot:
              type: integer
            chargeback:
              type: integer
        by_version:
          type: object
          description: 'Reports per address family.'
          properties:
            4:
              type: integer
            6:
              type: integer
    IpMonitor:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        cadence:
          type: string
          enum:
            - daily
            - weekly
            - monthly
        ip_count:
          type: integer
          description: 'Distinct addresses in the list, up to 500.'
        next_run_at:
          type: string
          format: date-time
        last_run_at:
          type:
            - string
            - 'null'
          format: date-time
          description: 'When the last run finished; null before the first run.'
        last_summary:
          allOf:
            -
              $ref: '#/components/schemas/IpMonitorSummary'
            -
              type:
                - object
                - 'null'
              description: "The last run's totals; null until the first run finishes."
        webhook_url:
          type:
            - string
            - 'null'
          description: 'Where a reported run is POSTed, if this monitor has a webhook.'
        webhook_events:
          type: string
          enum:
            - changes
            - every_run
          description: 'Which runs are posted: only the ones that found something new, or every one.'
        webhook_status:
          type:
            - string
            - 'null'
          enum:
            - delivered
            - failed
            - null
          description: 'How the last delivery went; null before the first one.'
        webhook_detail:
          type:
            - string
            - 'null'
          description: 'What the endpoint answered — the HTTP status, or the transport failure when it answered nothing. The response body is never stored.'
        prunes_after_days:
          type: integer
          description: 'How long this list is kept if nobody runs it. A monitor in active use is never pruned; one that has stopped being run is deleted along with the identifiers and results it stored.'
        created_at:
          type: string
          format: date-time
    IpMonitorSummary:
      type: object
      properties:
        total:
          type: integer
        clean:
          type: integer
        flagged:
          type: integer
          description: 'Addresses on a threat blocklist, Tor exits, or scoring high.'
        reserved:
          type: integer
          description: 'Private and special-use addresses, which nothing can monitor.'
        changed:
          type: integer
          description: 'Addresses whose status differs from the previous run.'
        newly_flagged:
          type: integer
          description: 'Addresses that were not flagged in the previous run and are now; these are the ones the alert email lists.'
        credits_used:
          type: integer
        stopped_reason:
          type:
            - string
            - 'null'
          enum:
            - insufficient_credits
            - null
          description: 'Set when the run stopped before the end of the list.'
    IpMonitorResult:
      type: object
      properties:
        status:
          type: string
          enum:
            - clean
            - flagged
            - reserved
        flags:
          type: array
          items:
            type: string
            enum:
              - blocklisted
              - attack_source
              - tor
              - high_risk
          description: 'The conditions that fired; empty for a clean address. attack_source means an attack-source list saw the address attacking.'
        blocklist:
          type:
            - string
            - 'null'
          description: 'The threat blocklist that names the address, when blocklisted fired.'
        risk_score:
          type:
            - integer
            - 'null'
        risk_level:
          type:
            - string
            - 'null'
          enum:
            - low
            - medium
            - high
            - null
        changed:
          type: boolean
          description: 'Whether the status differs from the previous run; false on the first run.'
    Source:
      type: object
      description: 'A dataset consulted for the answer and the version that was current.'
      properties:
        dataset:
          type: string
          example: live-dns
        version:
          type: string
          example: '2026-09-03'
    Meta:
      type: object
      description: 'The billing block on every lookup.'
      properties:
        credits_used:
          type: integer
          description: 'Credits this call cost (0 or 1).'
        credits_remaining:
          type:
            - integer
            - 'null'
          description: 'The balance after this call. Null on a sandbox answer, where there is no account whose balance it could be.'
        cache_hit:
          type: boolean
          description: 'Whether the answer was a free 7-day repeat.'
        request_id:
          type:
            - string
            - 'null'
          description: 'Matches the X-Request-Id header and every log line of the request.'
        sandbox:
          type: boolean
          description: 'Present and true only on an answer from the published sandbox key: the values are canned and nothing was billed or stored.'
    EmailMeta:
      allOf:
        -
          $ref: '#/components/schemas/Meta'
        -
          type: object
          properties:
            suppressed:
              type: boolean
              description: 'Whether the answer was served from the suppression list (always false on a single lookup).'
            refreshed:
              type: boolean
              description: 'Whether an earlier answer inside the repeat window was discarded for this call because of `refresh` or an exceeded `max_age`.'
            settling:
              type: boolean
              description: 'Present only when a callback was requested — true when a re-check was scheduled, false when the answer was already final.'
            fair_use_billed:
              type: boolean
              description: "True when this answer is an undeliverable one that spent a mailbox probe and was billed 1 credit because the account had used up its email fair use: undeliverable answers that spent a probe are free up to max(500, the account's billed email answers) in a rolling 30 days. False on every other answer, including undeliverable ones inside the allowance and ones decided without a probe (invalid syntax, no mail servers, a local part the provider never issues), which are never counted."
    PhoneMeta:
      allOf:
        -
          $ref: '#/components/schemas/Meta'
        -
          type: object
          properties:
            suppressed:
              type: boolean
              description: 'Whether the answer was served from the suppression list (always false on a single lookup).'
            opted_out:
              type: boolean
              description: 'Whether your suppression list holds the number as opted out. A single lookup still answers and scores the number in full; under the default SMS policy (block_opted_out) it answers ok_to_send false with blocked_by opted_out. Switch that rule off to decide it like any other number, for instance for one-time codes, because the person typing it in may be the recipient asking for a code again.'
    IpMeta:
      allOf:
        -
          $ref: '#/components/schemas/Meta'
        -
          type: object
          properties:
            ip_lookups_prepaid:
              type: integer
              minimum: 0
              maximum: 4
              description: 'IP lookups the account has already paid for and not yet used, after this call. One credit pays for five answered IP lookups: the answered lookup that opens a block costs 1 credit (`credits_used` 1) and leaves 4 prepaid, and each of the next four costs nothing (`credits_used` 0). Reserved and private ranges, unallocated space, addresses nothing knows and 7-day repeats use none. The count belongs to the account, so lookups from other keys, the dashboard, bulk runs and monitors share it. Absent on a sandbox answer, which has no account behind it.'
    ErrorEnvelope:
      type: object
      description: 'Every failure answers this shape with a stable code to branch on.'
      properties:
        success:
          type: boolean
          const: false
        error:
          type: object
          properties:
            code:
              type: string
              description: 'A stable code such as INSUFFICIENT_CREDITS. See https://spaw.co/docs/errors.'
            message:
              type: string
            errors:
              type: object
              description: 'Present on VALIDATION_FAILED — the failing fields and their messages.'
              additionalProperties:
                type: array
                items:
                  type: string
            request_id:
              type:
                - string
                - 'null'
    EmailResult:
      type: object
      description: 'The 46 documented fields of an email verification, plus the sources consulted.'
      properties:
        email:
          type: string
          description: 'The address as checked — local part preserved, domain lowercased and converted to punycode. Wrappers people paste (a display name, a `mailto:` prefix, spreadsheet quotes, trailing punctuation, invisible characters) are stripped first. Input that still fails the syntax check is echoed back.'
        normalized_email:
          type:
            - string
            - 'null'
          description: 'The canonical inbox the mail actually reaches — lowercased, plus-tag stripped, Gmail dots and googlemail.com folded away. Dedupe your list on this, not on the raw input.'
        is_alias:
          type:
            - boolean
            - 'null'
          description: 'Whether the address carries a plus-tag (mia+promo@…) — the same inbox under a disposable label.'
        is_gibberish:
          type:
            - boolean
            - 'null'
          description: 'Whether the local part reads as keyboard mash. Deliberately conservative — y counts as a vowel, digit-only locals are never flagged.'
        looks_generated:
          type:
            - boolean
            - 'null'
          description: "Whether the local part looks machine-made or a placeholder — a long hex string, a UUID, a base64-looking run, one or two letters followed by four or more digits, a word followed by six or more digits, or a placeholder word such as test, fake, asdf or noname. A signal about the username's shape, never about the person."
        local_part_pattern:
          type:
            - string
            - 'null'
          enum:
            - first.last
            - first_last
            - first-last
            - firstlast
            - f.last
            - flast
            - first.l
            - firstl
            - last.first
            - lastfirst
            - first
            - last
            - numeric
            - word
            - mixed
            - other
            - null
          description: 'The shape of the username once the plus-tag and up to four trailing digits are removed — a naming convention (first.last, flast, …) recognised with the bundled name lists, a lone first name or surname, digits only, a plain word, letters mixed with digits, or other.'
        first_name:
          type:
            - string
            - 'null'
          description: 'The first-name token the bundled dictionary recognised in a name-shaped username, lowercased as typed. Null when no full first name was recognised; the list is small, so a lone name it does not know reads as a surname or a word.'
        last_name:
          type:
            - string
            - 'null'
          description: 'The surname token the bundled dictionary recognised, lowercased as typed. Null when none was.'
        deliverable:
          type: string
          enum:
            - deliverable
            - risky
            - undeliverable
          description: 'The verdict. Risky covers a burner, a typo-squat, a parked domain, mail served from a blocklisted network, a web-only domain, a full mailbox, a no-reply or other role inbox, a mailbox that exists again after answering not-found, a catch-all, or a mailbox the handshake could not confirm; undeliverable means do not send.'
        reason:
          type:
            - string
            - 'null'
          enum:
            - invalid_syntax
            - invalid_local_part
            - no_mx_records
            - null_mx
            - mx_unresolvable
            - mailbox_not_found
            - mailbox_disabled
            - likely_typo
            - parked_domain
            - disposable
            - mx_blocklisted
            - implicit_mx
            - mailbox_full
            - no_reply
            - role
            - recycled_mailbox
            - catch_all
            - unverified
            - null
          description: 'Why the verdict is not deliverable. Each value is explained at https://spaw.co/docs/reasons.'
        risk_score:
          type: integer
          minimum: 0
          maximum: 100
          description: 'The sum of the weights in `risk_signals`, capped at 100 — disposable 80, parked domain 60, typo-squat 60, blocklisted mail network 60, web-only domain 30, role 30, catch-all 30 (15 behind a security gateway), domain under 30 days old 30, recycled mailbox 30, full mailbox 20, gibberish 20, generated username 20, no-reply 20, widely seen 20, domain under 180 days 10, no SPF on a business domain 10, unverified mailbox 10, registration ending within 30 days 10, plus-tag alias 5, relay alias 5. Undeliverable is always 100 and lists no signals.'
        risk_level:
          type: string
          enum:
            - low
            - medium
            - high
          description: 'The score bucketed — medium from 30, high from 60.'
        risk_signals:
          type: array
          description: 'The signals that fired, in the published order, each with the weight applied and the dataset it was read from; the weights sum to `risk_score`. Empty on an undeliverable verdict.'
          items:
            type: object
            properties:
              signal:
                type: string
                enum:
                  - disposable
                  - parked_domain
                  - likely_typo
                  - mx_blocklisted
                  - implicit_mx
                  - role
                  - catch_all
                  - young_domain
                  - recycled_mailbox
                  - mailbox_full
                  - gibberish
                  - generated
                  - no_reply
                  - widely_seen
                  - catch_all_gateway
                  - new_domain
                  - no_spf
                  - unverified
                  - expiring_domain
                  - alias
                  - relay
              weight:
                type: integer
              dataset:
                type: string
        syntax_valid:
          type: boolean
          description: 'Whether the address parses under RFC 5322 grammar.'
        domain:
          type:
            - string
            - 'null'
          description: 'The ASCII (punycode) domain used for the DNS and list checks.'
        domain_category:
          type:
            - string
            - 'null'
          enum:
            - relay
            - disposable
            - webmail
            - military
            - government
            - academic
            - other
            - null
          description: 'The class of the mail domain — a permanent alias service, a burner, a free mailbox provider, or a military, government or academic institution recognised from its suffix — else other. Never "business", because the data cannot tell a company from a personal domain.'
        mx_found:
          type:
            - boolean
            - 'null'
          description: 'Whether the domain has usable mail servers. A null MX counts as false, and so do MX records that only point at loopback, private, or non-existent hosts.'
        mx_implicit:
          type:
            - boolean
            - 'null'
          description: 'True when the domain publishes no MX record and mail would fall back to its own address (RFC 5321). Such web-only domains rarely accept mail, so the verdict is risky and no mailbox check runs.'
        mx_provider:
          type:
            - string
            - 'null'
          description: 'Who runs the domain''s mail, from its MX hostnames — google, microsoft, zoho, proofpoint, mimecast, and a dozen more, or "other" for self-hosted.'
        mx_provider_type:
          type:
            - string
            - 'null'
          enum:
            - mailbox_provider
            - security_gateway
            - transactional
            - hosting
            - self_hosted
            - other
            - null
          description: "What the provider is — a mailbox host, a security gateway (Proofpoint, Mimecast, Barracuda) that accepts every recipient and sorts them out behind, a transactional relay, a web host's bundled mail, the domain's own servers, or an unknown third party."
        mx_blocklisted:
          type:
            - boolean
            - 'null'
          description: 'Whether any MX host resolves into a network on the threat blocklists the IP product installs (Spamhaus DROP and ASN-DROP, Feodo Tracker). False is a checked negative; null means no usable MX, unresolvable hosts, or no blocklist feed installed.'
        mx_network_type:
          type:
            - string
            - 'null'
          enum:
            - hosting
            - isp
            - education
            - government
            - null
          description: "The class of network the first resolvable MX host sits in — hosting when inside a datacenter range, else the autonomous system's kind from its registered name. Null when unknown."
        has_spf:
          type:
            - boolean
            - 'null'
          description: 'Whether the domain publishes an SPF policy.'
        dmarc_policy:
          type:
            - string
            - 'null'
          enum:
            - none
            - quarantine
            - reject
            - null
          description: "The domain's DMARC policy, or null when it publishes none."
        domain_registered_at:
          type:
            - string
            - 'null'
          format: date
          description: 'The registration date from public RDAP registry data. Null for free providers and burners (not consulted) and where the registry does not publish it.'
        domain_age_days:
          type:
            - integer
            - 'null'
          description: 'Days since registration. Under 30 days adds 30 to the risk score, under 180 adds 10.'
        domain_expires_at:
          type:
            - string
            - 'null'
          format: date
          description: 'When the registration ends, from the same RDAP record as `domain_registered_at`, so it is consulted only for business domains. A date inside the next 30 days adds the expiring_domain weight.'
        parked:
          type:
            - boolean
            - 'null'
          description: "Whether the domain's nameservers belong to a parking or for-sale service. Evaluated only when the domain has usable mail servers; a parked domain that still accepts mail is harvesting it and answers the parked_domain reason."
        disposable:
          type:
            - boolean
            - 'null'
          description: 'Whether the domain — or a parent handing out subdomains — is a known burner provider, or routes its mail through one.'
        is_relay:
          type:
            - boolean
            - 'null'
          description: "Whether the domain belongs to a permanent alias or forwarding service (Firefox Relay, SimpleLogin, Addy.io, DuckDuckGo Email Protection, Proton Pass, Sign in with Apple's private relay, …). A relay address is a real person's mailbox behind a name they control — never a burner, so `disposable` is false for it even where public burner lists carry the domain. Only domains where every address is an alias count, so an ordinary iCloud Mail or Proton Mail mailbox is false, and an iCloud+ Hide My Email alias, which sits on icloud.com beside those mailboxes, cannot be told apart from one."
        relay_service:
          type:
            - string
            - 'null'
          description: "The alias service's name when `is_relay` is true."
        role:
          type:
            - boolean
            - 'null'
          description: 'Whether the local part is a role account such as admin@, support@, or billing@.'
        role_type:
          type:
            - string
            - 'null'
          enum:
            - no_reply
            - postmaster
            - support
            - sales
            - info
            - admin
            - billing
            - marketing
            - careers
            - legal
            - other
            - null
          description: 'Which kind of role inbox it is. `no_reply` is recognised by pattern even when the open role list lacks the spelling, makes `role` true, and answers its own verdict reason because mail to it is discarded by design.'
        free_provider:
          type:
            - boolean
            - 'null'
          description: 'Whether the domain is a known free mailbox provider like gmail.com or outlook.com.'
        smtp_checked:
          type: boolean
          description: 'Whether a definitive mailbox-level SMTP answer was obtained. When mail servers will not say, it reports false and the verdict is risky with reason unverified instead of guessing.'
        smtp_checked_at:
          type:
            - string
            - 'null'
          format: date-time
          description: 'When the mailbox probe actually ran. Confirmed answers are shared across customers for up to a week, so this can predate the lookup.'
        mailbox_exists:
          type:
            - boolean
            - 'null'
          description: 'Whether the exact inbox exists, from the SMTP-level check. Null when unconfirmed, or when a catch-all domain makes existence unknowable.'
        previously_invalid:
          type:
            - boolean
            - 'null'
          description: 'The mailbox exists now, but a definitive "no such mailbox" answer was recorded for it earlier — the shape of a recycled spam trap, answered with the recycled_mailbox reason. False means it exists with no such history; null whenever `mailbox_exists` is not true.'
        previously_invalid_at:
          type:
            - string
            - 'null'
          format: date
          description: 'When that earlier not-found answer was recorded. The memory is kept for a year under a keyed hash of the address.'
        catch_all:
          type:
            - boolean
            - 'null'
          description: 'Whether the domain accepts mail for every address, which turns the verdict risky. Null when no mailbox answer for this address says so, because no handshake ran, the handshake was unverified, or your own delivery report confirmed the mailbox. A catch-all remembered for the domain answers true without a new handshake.'
        smtp_reason:
          type:
            - string
            - 'null'
          enum:
            - no_mailbox
            - mailbox_full
            - disabled
            - greylisted
            - timeout
            - no_connect
            - mx_unreachable
            - provider_unavailable
            - delivered_recently
            - null
          description: 'The detail behind the mailbox check. Each value is explained at https://spaw.co/docs/reasons.'
        mailbox_confidence:
          type:
            - integer
            - 'null'
          minimum: 0
          maximum: 100
          description: "For catch-all domains and unanswered checks only — a recomputable estimate that the specific mailbox exists, from the signals already in the answer and reported delivery outcomes for the domain. A relay alias stays at 50 and moves only on the evidence against it, a bounce rate of 50% or more or a plus-tag, because the domain's records describe the relay operator. A published heuristic, not a probe."
        widely_seen:
          type:
            - boolean
            - 'null'
          description: 'Whether at least three OTHER accounts looked this address up inside the last 24 hours — an address hitting many signup forms at once. Counted from a keyed hash and account ids, never the address; null where nobody could count (the guest demo, test addresses, invalid syntax).'
        did_you_mean:
          type:
            - string
            - 'null'
          description: 'A suggested correction ("mia@gmail.com" for mia@gmail.con), offered after the mail-server check fails or when the domain is a known typo-squat.'
        ok_to_send:
          type: boolean
          description: "The account's own sending policy applied to this answer — undeliverable is always false; otherwise false when a blocked signal fired, the risk score is above the policy's ceiling, or an unconfirmed mailbox's confidence is below its floor. The guest demo and the sandbox use the published defaults."
        blocked_by:
          type:
            - string
            - 'null'
          description: 'The first rule that decided a false — `undeliverable`, a signal name from `risk_signals`, `risk_score` or `mailbox_confidence` — in that order. Null when ok_to_send is true.'
        sources:
          type: array
          items:
            $ref: '#/components/schemas/Source'
          description: 'The datasets and versions behind this answer.'
    BatchResponse:
      type: object
      properties:
        success:
          type: boolean
        data:
          type: object
          properties:
            results:
              type: array
              description: 'One entry per processed address, in input order.'
              items:
                type: object
                properties:
                  index:
                    type: integer
                    description: "The item's zero-based position in the submitted list. A batch truncated by `stopped_reason` answers only a prefix of the list, so this is what lines an answer up with its submission."
                  input:
                    type: string
                    description: 'The address exactly as it was submitted.'
                  data:
                    $ref: '#/components/schemas/EmailResult'
                  meta:
                    type: object
                    properties:
                      credits_used:
                        type: integer
                      cache_hit:
                        type: boolean
                      suppressed:
                        type: boolean
                      refreshed:
                        type: boolean
                        description: "Whether this address's earlier answer was discarded because of `refresh` or an exceeded `max_age`."
        meta:
          type: object
          properties:
            requested:
              type: integer
              description: 'Addresses in the request.'
            processed:
              type: integer
              description: 'Addresses answered; lower than requested when the balance ran out.'
            credits_used:
              type: integer
            credits_remaining:
              type: integer
            stopped_reason:
              type:
                - string
                - 'null'
              enum:
                - insufficient_credits
                - key_spend_cap
                - null
              description: 'Why the batch stopped early: insufficient_credits when the account balance ran out, key_spend_cap when the calling key reached its daily credit cap.'
            request_id:
              type:
                - string
                - 'null'
    BulkDraft:
      type: object
      description: |
        A bulk run that has been read but not started: what we made of the list, what it can cost, and nothing queued and nothing billed. Start it with `draft_id` on the same endpoint. A draft nobody starts is deleted, with the rows it holds, `retention_hours` after it was made.
      properties:
        id:
          type: integer
          description: 'Send this back as `draft_id` to start the run.'
        product:
          type: string
          enum:
            - email
            - phone
            - ip
            - address
        source:
          type: string
          enum:
            - file
            - list
          description: '`file` came from a `csv` and has columns that can be remapped; `list` came from the item array and has none.'
        filename:
          type:
            - string
            - 'null'
          description: 'Always null over the API: a `csv` arrives as a string.'
        delimiter:
          type:
            - string
            - 'null'
          description: 'The separator character detected in the CSV.'
        delimiter_name:
          type:
            - string
            - 'null'
          enum:
            - comma
            - semicolon
            - tab
            - other
            - null
          description: 'The same thing in words, because a tab is invisible and a semicolon is easy to miss.'
        has_header:
          type: boolean
          description: 'Whether the first row was read as headings rather than data.'
        columns:
          type: array
          items:
            type: string
          description: "The CSV's own column names, in order. Empty for an item list."
        mapping:
          type: object
          additionalProperties:
            type: string
          description: 'Column index (as a string key) to the field that column fills.'
        mapping_source:
          type: string
          enum:
            - headers
            - guessed
            - list
          description: '`headers` means a column heading named the field. `guessed` means none did, and the column was chosen by the shape of the values in it — the heading names are English only, so a German or French export lands here every time and the guess is the thing to check before starting. `list` means there were no columns.'
        fields:
          type: array
          items:
            type: string
          description: 'The fields a column of this product may be mapped to.'
        sample:
          type: array
          description: 'The first rows, as the run will read them.'
          items:
            type: object
            properties:
              cells:
                type: array
                items:
                  type: string
                description: "The row's own cells, in the file's order."
              fields:
                type: object
                additionalProperties:
                  type:
                    - string
                    - 'null'
                description: 'What those cells were read as.'
        total:
          type: integer
          description: 'Rows the run would carry.'
        dropped:
          type: integer
          description: 'Rows past the per-run cap: counted, never stored, never run.'
        duplicate_count:
          type: integer
          description: 'Rows repeating a value already in the list. They are looked up once and the repeats are free — except on an IP run with privacy true, which writes no repeat marker, so there this is 0 and every row is billed.'
        unique_count:
          type: integer
          description: '`total` minus `duplicate_count`: the rows that can be charged for.'
        credits_per_row:
          type: integer
          description: 'A lookup, plus the licensed extra when the run asked for one. On an IP run it is 1, the most one row can cost: one credit pays for five answered addresses.'
        credits_upper_bound:
          type: integer
          description: |
            THE MOST this run can cost. It is a ceiling, not a quote, and it is named that way because a figure called an estimate gets quoted at people. A row comes back free for reasons no preview can know in advance — a repeat inside the seven-day window, an address on your suppression list, a domain already known to be catch-all, a verdict that is not charged for, a provider outage — so the real figure is usually lower and never higher. It is `unique_count` × `credits_per_row`, except on an IP run, where one credit pays for five answered addresses and the ceiling is `unique_count` divided by five, rounded up; IP lookups the account already holds prepaid are not taken off it, because other lookups on the account may use them first. Read `credits_used` on the finished job for what it actually cost.
        credit_balance:
          type: integer
          description: "The account's balance when the preview was made."
        credits_short:
          type: integer
          description: 'What the balance would have to gain to cover the ceiling. Zero means the ceiling is covered; it promises nothing about the real cost.'
        retention_hours:
          type: integer
          description: 'How long an unstarted draft and its stored rows are kept.'
        expires_at:
          type: string
          format: date-time
          description: 'After this, the draft is gone and the list has to be sent again.'
    SendingPolicy:
      type: object
      description: "The account's rules behind ok_to_send. Undeliverable verdicts are blocked whatever these say; blocked_by names the first rule that decided, in this order: undeliverable, the first blocked signal in the published weights' order, risk_score, mailbox_confidence."
      properties:
        max_risk_score:
          type:
            - integer
            - 'null'
          minimum: 0
          maximum: 100
          description: 'Block when risk_score is above this; null ignores the score. Default 60.'
        block_signals:
          type: array
          items:
            type: string
          description: "Signal names that block whenever they fire, in the published weights' order. Default: disposable, likely_typo, parked_domain, mx_blocklisted, no_reply."
        min_mailbox_confidence:
          type:
            - integer
            - 'null'
          minimum: 0
          maximum: 100
          description: 'Block an unconfirmed mailbox whose mailbox_confidence is below this; null (the default) ignores it. A confirmed mailbox never fails this rule.'
        is_default:
          type: boolean
          description: 'Whether the policy is exactly the published defaults.'
        updated_at:
          type:
            - string
            - 'null'
          format: date-time
          description: 'When the policy was last changed; null while it is the default.'
    ShippingPolicy:
      type: object
      description: "The account's rules behind ok_to_ship. An address that cannot stand as written is blocked whatever these say; blocked_by names the first rule that decided, in this order: invalid, the first blocked signal in the published weights' order, risk_score, unconfirmed."
      properties:
        max_risk_score:
          type:
            - integer
            - 'null'
          minimum: 0
          maximum: 100
          description: 'Block when risk_score is above this; null ignores the score. Default 60.'
        block_signals:
          type: array
          items:
            type: string
          description: "Signal names that block whenever they fire, in the published weights' order. Default: undeliverable, placeholder, vacant."
        require_exists:
          type: boolean
          description: 'Block unless a register confirmed the building (exists true). Default false.'
        is_default:
          type: boolean
          description: 'Whether the policy is exactly the published defaults.'
        updated_at:
          type:
            - string
            - 'null'
          format: date-time
          description: 'When the policy was last changed; null while it is the default.'
    PhonePolicy:
      type: object
      description: "The account's rules behind ok_to_send on phone answers. An invalid number is blocked whatever these say; blocked_by names the first rule that decided, in this order: invalid, opted_out, country, line_type, the first blocked signal in the published weights' order, risk_score, then unreachable or unconfirmed. A batch, bulk or monitored run answers suppressed for a number served from the suppression list. A fraud and deliverability decision, not consent and not a do-not-call scrub."
      properties:
        max_risk_score:
          type:
            - integer
            - 'null'
          minimum: 0
          maximum: 100
          description: 'Block when risk_score is above this; null ignores the score. Default 60.'
        block_signals:
          type: array
          items:
            type: string
          description: "Signal names that block whenever they fire, in the published weights' order. Default: fictional, disposable, reported_abuse, reported_abuse_widely, premium_rate."
        allowed_countries:
          type:
            - array
            - 'null'
          items:
            type: string
          description: 'Send only to numbers of these regions, sorted. Null (the default) sends to any; a number with no country is outside every allow-list.'
        blocked_countries:
          type:
            - array
            - 'null'
          items:
            type: string
          description: 'Never send to numbers of these regions, sorted. Null (the default) blocks none.'
        blocked_line_types:
          type: array
          items:
            type: string
            enum:
              - mobile
              - fixed_line
              - voip
              - toll_free
              - premium_rate
              - shared_cost
              - personal_number
              - pager
              - uan
              - voicemail
              - unknown
          description: 'Line types that block, in the published order. Default: none. fixed_line_or_mobile can never be blocked.'
        require_reachable:
          type: boolean
          description: 'Block unless a live check reached the handset. Default false.'
        block_opted_out:
          type: boolean
          description: "Block a single or browser lookup of a number your suppression list holds as opted out (meta.opted_out true), as blocked_by opted_out. Default true. An OTP or transactional sender may switch it off; honouring an opt-out stays the account's obligation."
        is_default:
          type: boolean
          description: 'Whether the policy is exactly the published defaults.'
        updated_at:
          type:
            - string
            - 'null'
          format: date-time
          description: 'When the policy was last changed; null while it is the default.'
    DomainMonitor:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        cadence:
          type: string
          enum:
            - daily
            - weekly
            - monthly
        domain_count:
          type: integer
          description: 'Distinct domains in the list, up to 500.'
        next_run_at:
          type: string
          format: date-time
        last_run_at:
          type:
            - string
            - 'null'
          format: date-time
          description: 'When the last run finished; null before the first run.'
        last_summary:
          allOf:
            -
              $ref: '#/components/schemas/DomainMonitorSummary'
            -
              type:
                - object
                - 'null'
              description: "The last run's totals; null until the first run finishes."
        webhook_url:
          type:
            - string
            - 'null'
          description: 'Where a reported run is POSTed, if this monitor has a webhook.'
        webhook_events:
          type: string
          enum:
            - changes
            - every_run
          description: 'Which runs are posted: only the ones that found something new, or every one.'
        webhook_status:
          type:
            - string
            - 'null'
          enum:
            - delivered
            - failed
            - null
          description: 'How the last delivery went; null before the first one.'
        webhook_detail:
          type:
            - string
            - 'null'
          description: 'What the endpoint answered — the HTTP status, or the transport failure. The response body is never stored.'
        prunes_after_days:
          type: integer
          description: 'How long this list is kept if nobody runs it. A monitor in active use is never pruned; one that has stopped being run is deleted along with the domains and results it stored.'
        created_at:
          type: string
          format: date-time
    CreatedDomainMonitor:
      allOf:
        -
          $ref: '#/components/schemas/DomainMonitor'
        -
          type: object
          properties:
            webhook_secret:
              type:
                - string
                - 'null'
              description: 'The webhook signing secret, shown once at creation and never readable again. Null when the monitor has no webhook.'
    DomainMonitorSummary:
      type: object
      properties:
        total:
          type: integer
        ok:
          type: integer
        flagged:
          type: integer
          description: 'Domains that raised at least one flag.'
        dead:
          type: integer
          description: 'Domains with no usable mail servers.'
        invalid:
          type: integer
          description: 'Entries that are not hostnames.'
        changed:
          type: integer
          description: 'Domains whose status or standing flags differ from the previous run, or whose provider, SPF, DMARC, DKIM or MTA-STS moved.'
        newly_flagged:
          type: integer
          description: 'Domains that changed for the worse since the previous run; these are the ones the alert email lists.'
        credits_used:
          type: integer
        stopped_reason:
          type:
            - string
            - 'null'
          enum:
            - insufficient_credits
            - key_spend_cap
            - run_failed
            - run_failed_retrying
            - null
          description: 'Set when the run stopped before the end of the list, or died.'
    DomainMonitorResult:
      type: object
      properties:
        status:
          type: string
          enum:
            - ok
            - flagged
            - dead
            - invalid
        flags:
          type: array
          items:
            type: string
            enum:
              - parked
              - blocklisted
              - expiring
              - no_spf
              - no_dmarc
              - provider_changed
              - spf_changed
              - dmarc_changed
              - dkim_lost
              - mta_sts_lost
          description: 'The conditions that fired, in this order. The last five compare with the previous run and never fire on a first sighting.'
        changed:
          type: boolean
          description: 'Whether the status or standing flags differ from the previous run, or a compared fact moved; false on the first run.'
        valid:
          type: boolean
        mx_found:
          type:
            - boolean
            - 'null'
        mx_provider:
          type:
            - string
            - 'null'
        mx_provider_type:
          type:
            - string
            - 'null'
        has_spf:
          type:
            - boolean
            - 'null'
        spf_all:
          type:
            - string
            - 'null'
        dmarc_policy:
          type:
            - string
            - 'null'
        dkim_selectors:
          type:
            - array
            - 'null'
          items:
            type: string
        mta_sts:
          type:
            - boolean
            - 'null'
        parked:
          type:
            - boolean
            - 'null'
        mx_blocklisted:
          type:
            - boolean
            - 'null'
        domain_expires_at:
          type:
            - string
            - 'null'
          format: date
    BulkJob:
      type: object
      properties:
        id:
          type: integer
        status:
          type: string
          enum:
            - queued
            - processing
            - completed
            - failed
            - cancelled
        total:
          type: integer
          description: 'Rows in the run.'
        duplicate_count:
          type: integer
          description: 'Rows repeating an address already in the list; verified once.'
        processed:
          type: integer
        deliverable:
          type: integer
        risky:
          type: integer
        undeliverable:
          type: integer
        summary:
          allOf:
            -
              $ref: '#/components/schemas/EmailBulkSummary'
            -
              type:
                - object
                - 'null'
              description: "What the finished run's rows add up to; null until the run has settled, and again after a resume until the resumed run settles."
        credits_used:
          type: integer
        stopped_reason:
          type:
            - string
            - 'null'
          enum:
            - insufficient_credits
            - null
        cancel_requested:
          type: boolean
        webhook_status:
          type:
            - string
            - 'null'
          enum:
            - pending
            - delivered
            - failed
            - null
          description: "The last delivery's state: `pending` while one has been asked for and has not answered yet, then `delivered` or `failed`. Null before the first one."
        webhook_detail:
          type:
            - string
            - 'null'
          description: 'Why the attempt ended as it did: the HTTP status the endpoint answered with, or the transport failure when it answered at all. The response body is never stored.'
        created_at:
          type: string
          format: date-time
        finished_at:
          type:
            - string
            - 'null'
          format: date-time
    EmailBulkSummary:
      type: object
      description: "What a finished bulk email run's rows add up to, built from the same cells the result file holds so it can never disagree with the download."
      properties:
        rows:
          type: integer
          description: 'Rows written, repeats included.'
        duplicates:
          type: integer
          description: 'Rows that repeated an address already in the list (verified once, free).'
        verdicts:
          type: object
          properties:
            deliverable:
              type: integer
            risky:
              type: integer
            undeliverable:
              type: integer
        reasons:
          type: object
          description: 'Rows per verdict reason, for every reason the run produced; rows with no reason are not counted.'
          additionalProperties:
            type: integer
        risk:
          type: object
          properties:
            low:
              type: integer
            medium:
              type: integer
            high:
              type: integer
        providers:
          type: array
          description: 'The ten most frequent mail providers (mx_provider), most rows first.'
          items:
            type: object
            properties:
              provider:
                type: string
              rows:
                type: integer
        domains:
          type: array
          description: 'The ten most frequent domains, most rows first.'
          items:
            type: object
            properties:
              domain:
                type: string
              rows:
                type: integer
        categories:
          type: object
          description: 'Rows per domain_category.'
          additionalProperties:
            type: integer
        signals:
          type: object
          description: 'How many rows answered true for each flag.'
          properties:
            disposable:
              type: integer
            relay:
              type: integer
            role:
              type: integer
            catch_all:
              type: integer
            parked:
              type: integer
            mx_blocklisted:
              type: integer
            previously_invalid:
              type: integer
            widely_seen:
              type: integer
            generated:
              type: integer
        blocked:
          type: integer
          description: "Rows the account's sending policy would hold back (ok_to_send false)."
    BulkResultRows:
      type: object
      description: "A bulk run's result rows as JSON, streamed row by row. Each row is keyed by the result file's own header, so a run created from an uploaded file leads with that file's own columns."
      properties:
        success:
          type: boolean
        data:
          type: object
          properties:
            job:
              type: object
              description: 'The run these rows belong to, in the same shape the polling endpoint answers.'
            results:
              type: array
              description: 'One object per row, in input order, filtered by `variant`.'
              items:
                type: object
                additionalProperties:
                  type:
                    - string
                    - boolean
                    - 'null'
                  description: 'The cell as the CSV carries it — an empty cell is null, `true`/`false` are booleans, everything else is the string as written.'
        meta:
          type: object
          properties:
            variant:
              type: string
              description: 'The variant the rows were filtered to.'
            request_id:
              type:
                - string
                - 'null'
    DomainIntelligence:
      type: object
      description: 'The 31 documented keys of a domain answer, plus the sources consulted. The sender-side policies (DKIM, MTA-STS, TLS-RPT, BIMI, the SPF and DMARC details) are answered here and not on the per-address lookup; everything DNS-bound is evaluated only when the domain has usable mail servers.'
      properties:
        domain:
          type: string
          description: 'The domain as checked — lowercased, punycode form.'
        valid:
          type: boolean
          description: 'Whether the input is a syntactically valid hostname. When false, every other field is null.'
        domain_category:
          type:
            - string
            - 'null'
          enum:
            - relay
            - disposable
            - webmail
            - military
            - government
            - academic
            - other
            - null
          description: 'The class of the mail domain, as on the per-address lookup.'
        mx_found:
          type:
            - boolean
            - 'null'
          description: 'Whether the domain can receive mail — MX records, or the RFC 5321 implicit fallback.'
        mx_implicit:
          type:
            - boolean
            - 'null'
          description: 'True when the domain has no MX record and mail would fall back to its own address.'
        catch_all:
          type:
            - boolean
            - 'null'
          description: 'True once any address at the domain has answered catch-all within the last week; null when not known.'
        mx_provider:
          type:
            - string
            - 'null'
          description: 'The mail infrastructure behind the MX hosts.'
        mx_provider_type:
          type:
            - string
            - 'null'
          enum:
            - mailbox_provider
            - security_gateway
            - transactional
            - hosting
            - self_hosted
            - other
            - null
          description: 'What the provider is, as on the per-address lookup.'
        mx_blocklisted:
          type:
            - boolean
            - 'null'
          description: 'Whether any MX host resolves into a network on the threat blocklists; null when unresolvable or no feed is installed.'
        mx_network_type:
          type:
            - string
            - 'null'
          enum:
            - hosting
            - isp
            - education
            - government
            - null
          description: 'The class of network the first resolvable MX host sits in.'
        has_spf:
          type:
            - boolean
            - 'null'
        spf_all:
          type:
            - string
            - 'null'
          enum:
            - '-all'
            - ~all
            - '?all'
            - +all
            - null
          description: "The qualifier on the policy's `all` term (a redirect target's when the record hands off). Null without SPF or when none is published."
        spf_lookup_count:
          type:
            - integer
            - 'null'
          description: 'DNS-querying terms the policy needs once include: and redirect= are followed. RFC 7208 allows ten; more is a permanent error at every receiver.'
        dmarc_policy:
          type:
            - string
            - 'null'
          enum:
            - none
            - quarantine
            - reject
            - null
        dmarc_pct:
          type:
            - integer
            - 'null'
          description: 'The pct tag, 100 when a valid record omits it.'
        dmarc_rua:
          type:
            - boolean
            - 'null'
          description: 'Whether an aggregate-report address (rua=) is published.'
        dkim_selectors:
          type:
            - array
            - 'null'
          items:
            type: string
          description: 'The common selectors (google, selector1, selector2, k1, fm1, …) that publish a DKIM key. Empty when none of them does; null when the lookups failed.'
        mta_sts:
          type:
            - boolean
            - 'null'
          description: 'Whether `_mta-sts.<domain>` publishes an MTA-STS policy record.'
        tls_rpt:
          type:
            - boolean
            - 'null'
          description: 'Whether `_smtp._tls.<domain>` publishes a TLS-RPT record.'
        bimi:
          type:
            - boolean
            - 'null'
          description: 'Whether `default._bimi.<domain>` publishes a BIMI record.'
        domain_registered_at:
          type:
            - string
            - 'null'
          format: date
        domain_age_days:
          type:
            - integer
            - 'null'
        domain_expires_at:
          type:
            - string
            - 'null'
          format: date
          description: 'When the registration ends, from RDAP.'
        domain_updated_at:
          type:
            - string
            - 'null'
          format: date
          description: "The registry's last-changed date."
        registrar:
          type:
            - string
            - 'null'
          description: "The registrar entity's name as the registry publishes it."
        registrant_redacted:
          type:
            - boolean
            - 'null'
          description: 'Whether the registrant contact is withheld (a privacy service, a redaction notice, or no registrant published). Null when the registry publishes no contacts at all.'
        parked:
          type:
            - boolean
            - 'null'
          description: 'Whether the nameservers belong to a parking or for-sale service.'
        disposable:
          type:
            - boolean
            - 'null'
        is_relay:
          type:
            - boolean
            - 'null'
          description: 'Whether the domain is a permanent alias service; never disposable.'
        relay_service:
          type:
            - string
            - 'null'
        free_provider:
          type:
            - boolean
            - 'null'
        sources:
          type: array
          items:
            $ref: '#/components/schemas/Source'
    FeedbackItem:
      type: object
      required:
        - email
        - outcome
      properties:
        email:
          type: string
          maxLength: 254
          description: 'The recipient. Normalized like a lookup; an address that does not parse is skipped and counted, not rejected.'
        outcome:
          type: string
          enum:
            - delivered
            - bounced
            - complained
          description: 'What happened. Report hard bounces only.'
        reason:
          type:
            - string
            - 'null'
          maxLength: 255
          description: "The provider's diagnostic, e.g. the SMTP reply. Stored as-is."
        occurred_at:
          type:
            - string
            - 'null'
          format: date-time
          description: 'When it happened, any parseable date. Defaults to now. A time more than five minutes in the future is refused; one inside that margin is recorded as now.'
    FeedbackSummary:
      type: object
      properties:
        window_days:
          type: integer
        total:
          type: integer
        outcomes:
          type: object
          properties:
            delivered:
              type: integer
            bounced:
              type: integer
            complained:
              type: integer
        by_verdict:
          type: object
          description: 'Outcome counts per verdict on file — deliverable, risky, undeliverable, unknown.'
          additionalProperties:
            type: object
            properties:
              delivered:
                type: integer
              bounced:
                type: integer
              complained:
                type: integer
        measured:
          type: object
          properties:
            deliverable_delivery_rate:
              type:
                - number
                - 'null'
              description: 'Delivered ÷ (delivered + bounced) among addresses answered deliverable.'
            undeliverable_bounce_rate:
              type:
                - number
                - 'null'
              description: 'Bounced ÷ (bounced + delivered) among addresses answered undeliverable.'
    Suppression:
      type: object
      properties:
        id:
          type: integer
        email:
          type: string
          description: 'The normalized address.'
        reason:
          type:
            - string
            - 'null'
          description: "The verdict reason for an entry a verdict added, `bounced` or `complained` for one your reports added (a complaint never goes stale, and neither does any import); null for an address you imported that was not already listed and has had no complaint since, because importing keeps an entry's reason and a later complaint is recorded on the import."
        source:
          type: string
          enum:
            - auto
            - imported
            - feedback
        added_at:
          type: string
          format: date-time
    Monitor:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        cadence:
          type: string
          enum:
            - weekly
            - monthly
        email_count:
          type: integer
          description: 'Addresses in the list, up to 500.'
        next_run_at:
          type: string
          format: date-time
        last_run_at:
          type:
            - string
            - 'null'
          format: date-time
          description: 'When the last run finished; null before the first run.'
        last_summary:
          allOf:
            -
              $ref: '#/components/schemas/MonitorSummary'
            -
              type:
                - object
                - 'null'
              description: "The last run's totals; null until the first run finishes."
        webhook_url:
          type:
            - string
            - 'null'
          description: 'Where a reported run is POSTed, if this monitor has a webhook.'
        webhook_events:
          type: string
          enum:
            - changes
            - every_run
          description: 'Which runs are posted: only the ones that found something new, or every one.'
        webhook_status:
          type:
            - string
            - 'null'
          enum:
            - delivered
            - failed
            - null
          description: 'How the last delivery went; null before the first one.'
        webhook_detail:
          type:
            - string
            - 'null'
          description: 'What the endpoint answered — the HTTP status, or the transport failure when it answered nothing. The response body is never stored.'
        prunes_after_days:
          type: integer
          description: 'How long this list is kept if nobody runs it. A monitor in active use is never pruned; one that has stopped being run is deleted along with the identifiers and results it stored.'
        created_at:
          type: string
          format: date-time
    MonitorSummary:
      type: object
      properties:
        deliverable:
          type: integer
        risky:
          type: integer
        undeliverable:
          type: integer
        decayed:
          type: integer
          description: 'Addresses that were deliverable in the previous run and no longer are.'
        credits_used:
          type: integer
        stopped_reason:
          type:
            - string
            - 'null'
          enum:
            - insufficient_credits
            - null
          description: 'Set when the run stopped before the end of the list.'
    HistoryRow:
      type: object
      properties:
        id:
          type: integer
        email:
          type: string
          description: 'The address as it was checked.'
        deliverable:
          type: string
          enum:
            - deliverable
            - risky
            - undeliverable
        reason:
          type:
            - string
            - 'null'
        risk_score:
          type:
            - integer
            - 'null'
        mx_provider:
          type:
            - string
            - 'null'
        smtp_checked:
          type: boolean
          description: 'Whether the mailbox handshake gave a definitive answer.'
        credits_used:
          type: integer
        cache_hit:
          type: boolean
          description: 'Whether the answer was a free 7-day repeat.'
        via:
          type:
            - string
            - 'null'
          description: 'The channel that ran the lookup: api · {key name}, dashboard, widget · {key name}, bulk run #{id}, monitor · {name}, or mcp · {key name}.'
        created_at:
          type: string
          format: date-time
    Account:
      type: object
      properties:
        name:
          type: string
        email:
          type: string
        plan:
          type: string
          example: free
          description: '`free`, or the key of the plan while it is paid up, billed monthly or yearly (`starter`, `growth`, `scale`, `volume`, `pro`, `business`, `enterprise`).'
        credits:
          type: object
          properties:
            balance:
              type: integer
            monthly_grant:
              type: integer
    PhoneFeedbackItem:
      type: object
      required:
        - phone
        - outcome
      properties:
        phone:
          type: string
          maxLength: 64
          description: 'The number, in any common notation.'
        outcome:
          type: string
          enum:
            - delivered
            - undelivered
            - abused
            - verified
            - opted_out
          description: 'verified: the recipient entered the code, which counts as a delivery. opted_out: the recipient unsubscribed; never scored, and it suppresses the number with reason opted_out until you remove the entry.'
        failure_class:
          type:
            - string
            - 'null'
          enum:
            - permanent
            - transient
            - not_sms_capable
            - sender_side
            - null
          description: 'For an undelivered outcome only: what kind of failure it was. permanent (the number no longer exists or is barred for good) and transient (the handset was off, out of coverage or busy) count against the number, like a failure with no class; not_sms_capable and sender_side are recorded and summarised, never scored. A class on any other outcome is a 422; an opt-out is its own outcome, not a class.'
        country:
          type:
            - string
            - 'null'
          description: 'An ISO 3166-1 alpha-2 region for a number written without a calling code; overrides the request-level country.'
        occurred_at:
          type:
            - string
            - 'null'
          format: date-time
          description: 'When it happened. Defaults to now. A time more than five minutes in the future is refused; one inside that margin is recorded as now.'
    PhoneFeedbackSummary:
      type: object
      properties:
        window_days:
          type: integer
        total:
          type: integer
        numbers:
          type: integer
          description: 'Distinct numbers reported on inside the window.'
        outcomes:
          type: object
          properties:
            delivered:
              type: integer
            undelivered:
              type: integer
            abused:
              type: integer
            verified:
              type: integer
            opted_out:
              type: integer
        verified_share:
          type:
            - number
            - 'null'
          description: 'Of the distinct numbers reported delivered or verified inside the window, the share reported verified (0 to 1, three decimals); null when there were none. With delivery receipts and verifications both reported, this is your OTP conversion.'
        undelivered_by_class:
          type: object
          description: 'The failed deliveries split by failure class; unclassified counts the ones reported without one. They add up to outcomes.undelivered.'
          properties:
            permanent:
              type: integer
            transient:
              type: integer
            not_sms_capable:
              type: integer
            sender_side:
              type: integer
              description: 'Failures on the sending side — a carrier filter, an unregistered campaign or sender, a rate limit — which never count against the numbers.'
            unclassified:
              type: integer
        top_undelivered_blocks:
          type: array
          description: 'Up to ten blocks with the most failed deliveries about the number (permanent, transient or unclassified), most first.'
          items:
            type: object
            properties:
              country:
                type:
                  - string
                  - 'null'
              block:
                type: string
                description: 'The first six digits of the national significant number.'
              undelivered:
                type: integer
              delivered:
                type: integer
              abused:
                type: integer
              verified:
                type: integer
              verified_share:
                type:
                  - number
                  - 'null'
                description: "The block's own verified_share: of its distinct numbers reported delivered or verified, the share reported verified."
    PhoneSuppression:
      type: object
      properties:
        id:
          type: integer
        phone:
          type: string
          description: 'The number in E.164.'
        reason:
          type:
            - string
            - 'null'
          enum:
            - unassigned_block
            - undelivered
            - abuse
            - opted_out
            - null
          description: "What put it on the list: a block nobody holds (from a lookup), the account's own undelivered or abuse reports, or an opt-out (reported by you or read off your SMS provider's opt-out code; only removing it clears it); null for a number you imported that was not already listed and has had no abuse report or opt-out since, because importing keeps an entry's reason and a later abuse report or opt-out is recorded on the import."
        source:
          type: string
          enum:
            - auto
            - feedback
            - imported
        added_at:
          type: string
          format: date-time
    PhoneMonitor:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        cadence:
          type: string
          enum:
            - weekly
            - monthly
          description: "No daily option: most regulator tables refresh weekly (NANPA's and the SMS-receiving lists daily), a weekly run already catches a number that stopped being valid or started being listed within days, and with the live check a daily run would pay for the network check seven times a week."
        number_count:
          type: integer
          description: 'Numbers in the list, up to 500.'
        country:
          type:
            - string
            - 'null'
          description: 'The region for numbers written without a calling code.'
        live_check:
          type: boolean
          description: 'Whether runs ask for the live carrier check where the service has it enabled.'
        next_run_at:
          type: string
          format: date-time
        last_run_at:
          type:
            - string
            - 'null'
          format: date-time
          description: 'When the last run finished; null before the first run.'
        last_summary:
          allOf:
            -
              $ref: '#/components/schemas/PhoneMonitorSummary'
            -
              type:
                - object
                - 'null'
              description: "The last run's totals; null until the first run finishes."
        webhook_url:
          type:
            - string
            - 'null'
          description: 'Where a reported run is POSTed, if this monitor has a webhook.'
        webhook_events:
          type: string
          enum:
            - changes
            - every_run
          description: 'Which runs are posted: only the ones that found something new, or every one.'
        webhook_status:
          type:
            - string
            - 'null'
          enum:
            - delivered
            - failed
            - null
          description: 'How the last delivery went; null before the first one.'
        webhook_detail:
          type:
            - string
            - 'null'
          description: 'What the endpoint answered — the HTTP status, or the transport failure when it answered nothing. The response body is never stored.'
        prunes_after_days:
          type: integer
          description: 'How long this list is kept if nobody runs it. A monitor in active use is never pruned; one that has stopped being run is deleted along with the identifiers and results it stored.'
        created_at:
          type: string
          format: date-time
    PhoneMonitorSummary:
      type: object
      properties:
        total:
          type: integer
        valid:
          type: integer
        invalid:
          type: integer
        unassigned:
          type: integer
          description: 'Numbers in a block nobody holds, including one the suppression list answered from a stored answer.'
        unreachable:
          type: integer
          description: 'Numbers the live check found unreachable; only with live_check.'
        suppressed:
          type: integer
          description: 'Numbers your suppression list answered with no stored answer: an import, your own failure or abuse reports, or an opt-out. Never counted as decay. Absent from a summary written before this status existed.'
        changed:
          type: integer
          description: 'Numbers whose status differs from the previous run.'
        decayed:
          type: integer
          description: 'Numbers that were valid in the previous run and no longer are.'
        newly_flagged:
          type: integer
          description: 'Numbers that answered valid and raised at least one change flag this run (see `flags` on the single-monitor endpoint); these are the ones the flag alert email lists. Absent from a summary written before flags existed.'
        credits_used:
          type: integer
        stopped_reason:
          type:
            - string
            - 'null'
          enum:
            - insufficient_credits
            - null
          description: 'Set when the run stopped before the end of the list.'
    PhoneMonitorFlag:
      type: string
      enum:
        - became_disposable
        - reported_abuse_widely
        - risk_high
        - line_type_changed
        - ported
        - carrier_changed
      description: 'A change a run found in a number that still answers valid, against the last valid answer recorded for it: became_disposable (an SMS-receiving website now lists it), reported_abuse_widely (three or more accounts reported abuse within 30 days), risk_high (risk_level rose from low or medium to high), line_type_changed (a different line type from the same basis; fixed_line_or_mobile turning into mobile or fixed_line is a sharper answer, not a change, and a type now read from another block holder, or from a holder where the plan decided it before, sets a new baseline instead), and with the live check on both runs ported (the network says it was not ported last time and is now) and carrier_changed (another network serves it). Each needs the earlier value to have been known, so a first sighting only records it. carrier_changed is not SIM-swap detection: the check sees the network serving the number, not the SIM in it.'
    PhoneResult:
      type: object
      properties:
        valid:
          type: boolean
          description: "Whether the number is assignable: valid for its numbering plan and, where the regulator's table is installed, inside an allocated block. A number in a range reserved for fiction (is_fictional) answers valid even where the regulator lists its block as protected."
        reason:
          type:
            - string
            - 'null'
          enum:
            - missing_country
            - invalid_country_code
            - too_short
            - too_long
            - not_a_number
            - invalid_length
            - invalid_number
            - unassigned_block
            - emergency_number
            - short_number
            - suppressed
            - null
          description: 'Why it is not valid. `unassigned_block` still carries the parsed number and its block status. `suppressed` appears only on batch, bulk and monitor answers served from your suppression list for an entry with no stored answer (an import, your own outcome reports, an opt-out), and keeps the parsed number, its formats and its country facts. Every other reason answers null fields.'
        e164:
          type:
            - string
            - 'null'
          example: '+14155550142'
        national:
          type:
            - string
            - 'null'
        international:
          type:
            - string
            - 'null'
        rfc3966:
          type:
            - string
            - 'null'
          description: 'The tel: URI form, e.g. tel:+1-415-555-0142.'
        country:
          type:
            - string
            - 'null'
          description: 'ISO 3166-1 alpha-2; null for non-geographic numbers.'
        country_name:
          type:
            - string
            - 'null'
          description: 'The English name of the country.'
        calling_code:
          type:
            - integer
            - 'null'
          description: 'The country calling code, e.g. 44.'
        continent:
          type:
            - string
            - 'null'
          description: 'Two-letter continent code: AF, AN, AS, EU, NA, OC, SA.'
        is_eu:
          type:
            - boolean
            - 'null'
          description: "Whether the number's country is a member of the European Union."
        currency:
          type:
            - string
            - 'null'
          description: "ISO 4217 code of the country's currency."
        line_type:
          type:
            - string
            - 'null'
          description: "mobile, fixed_line, fixed_line_or_mobile, voip, toll_free, premium_rate, shared_cost, personal_number, pager, uan, voicemail, or unknown. A UK 084 or 087 number answers shared_cost, as Ofcom's numbering plan classes it (a capped service charge; in the plan, premium rate is the 09 range). 0871 to 0873 still fall under Ofcom's premium-rate-services rules, so a sender that treats them as premium rate should block shared_cost too."
        line_type_source:
          type:
            - string
            - 'null'
          enum:
            - numbering_plan
            - block_allocation
            - null
          description: "numbering_plan when the numbering plan decides the type: libphonenumber's metadata, or where the regulator's plan types a range differently (drama ranges the metadata leaves out, such as Ofcom's 07700 900xxx and ARCEP's +262 639 98; UK 084 and 087, which the plan classes as shared cost); block_allocation when a North American fixed_line_or_mobile number was settled by its block holder (a wireless carrier's block is mobile, a virtual-number wholesaler's is voip)."
        carrier:
          type:
            - string
            - 'null'
          description: 'The block holder as the regulator lists it, or the carrier the metadata knows for the prefix. Reflects the original allocation, not a later port.'
        carrier_source:
          type:
            - string
            - 'null'
          enum:
            - numbering_plan
            - block_allocation
            - null
        block_status:
          type:
            - string
            - 'null'
          enum:
            - allocated
            - unassigned
            - reserved
            - null
          description: "What the regulator's table says about the block: allocated to a holder, unassigned (free, protected, quarantined: no number in it can be in service), or reserved for special use. Null when no table covers the region or the table says nothing about this number."
        region:
          type:
            - string
            - 'null'
          description: 'A geographic description, e.g. "San Francisco, CA".'
        is_geographic:
          type:
            - boolean
            - 'null'
          description: 'Whether the number is tied to a place (fixed lines and geographic mobiles) rather than a service such as toll-free.'
        timezones:
          type: array
          items:
            type: string
        extension:
          type:
            - string
            - 'null'
        is_fictional:
          type:
            - boolean
            - 'null'
          description: 'The number sits in a range reserved for fiction and drama (NANP 555-01XX; Ofcom, ARCEP, ACMA and ComReg drama ranges and numbers). Still valid, never in service.'
        is_virtual:
          type:
            - boolean
            - 'null'
          description: 'The block is held by a virtual-number wholesaler or VoIP provider. Null when no regulator table covers the number.'
        is_disposable:
          type:
            - boolean
            - 'null'
          description: 'The number is published by an SMS-receiving website, so anyone can read a code sent to it. Null until the list is installed.'
        disposable_seen_at:
          type:
            - string
            - 'null'
          format: date
          description: 'When the SMS-receiving list last saw the number, where the list says so. It scores disposable_stale instead of disposable once this date is more than a year old for a number the lists still carry, or more than 90 days old for a number every list has dropped, because the number may have been reassigned since.'
        risk_score:
          type:
            - integer
            - 'null'
          minimum: 0
          maximum: 100
          description: 'The sum of the weights in risk_signals, capped at 100. Null for invalid numbers.'
        risk_level:
          type:
            - string
            - 'null'
          enum:
            - low
            - medium
            - high
            - null
          description: 'high at 60 and above, medium at 30 and above.'
        risk_signals:
          type: array
          description: 'The signals behind the score, each with the weight applied and the dataset that produced it.'
          items:
            type: object
            properties:
              signal:
                type: string
                enum:
                  - fictional
                  - disposable
                  - disposable_stale
                  - reported_abuse
                  - reported_undelivered
                  - reported_abuse_widely
                  - premium_rate
                  - reserved_block
                  - virtual
                  - range_burst
                  - voip
                  - pager
                  - digit_pattern
                  - numbers_per_client
                  - disposable_range
                  - international_network
                  - shared_cost
                  - reported_unwanted_calls
              weight:
                type: integer
              dataset:
                type: string
        hlr_checked:
          type: boolean
          description: 'Whether a live carrier-network check answered. False while the check is not enabled on the service, and whenever the lookup did not ask for one.'
        reachable:
          type:
            - boolean
            - 'null'
          description: 'Whether the handset was reachable at check time.'
        ported:
          type:
            - boolean
            - 'null'
          description: 'Whether the number has been ported away from its original carrier.'
        current_carrier:
          type:
            - string
            - 'null'
          description: 'The carrier serving the number today, per the live check.'
        roaming:
          type:
            - boolean
            - 'null'
        carrier_code:
          type:
            - string
            - 'null'
          description: "The regulator's code for the block holder (the NANPA or CNAC operating company number, the ARCEP operator code, the UKE operator ID). Like carrier, it names the original allocation, not a later port; in a pooled US code it is the holder of the number's own thousands-block where NANPA lists that block. Null when no table row names one, including when carrier comes from the plan's prefix metadata."
        carrier_brand:
          type:
            - string
            - 'null'
          description: 'The network brand behind carrier ("AT&T" for NEW CINGULAR WIRELESS PCS, "Lumen" for QWEST CORPORATION), from a bundled, versioned map (carrier-brands in sources) matched by the holder''s OCN first and its name second, country by country. Null when the name is not mapped.'
        block_pooled:
          type:
            - boolean
            - 'null'
          description: "North American numbers only: the central-office code is pooled into thousands-blocks. In the United States carrier, carrier_code and line_type then describe the holder of the number's own thousands-block, from NANPA's block report; in Canada, and for a US block the report does not settle, they describe the code holder, not necessarily this number's block. Null outside the NANPA and CNAC tables."
        block_assigned_at:
          type:
            - string
            - 'null'
          format: date
          description: "When the regulator assigned the code or range (YYYY-MM-DD), where its table dates it (NANPA, CNAC, ARCEP); in a pooled US code, when NANPA assigned the number's own thousands-block. Null for a block its holder retained when the code was pooled, which NANPA leaves undated; for a pooled code the code holder answers for (in Canada, or a block NANPA's report does not settle), because the code's date says nothing about the number's block; and for most US codes assigned before 2001, which NANPA leaves undated too: a null does not mean new."
        region_level:
          type:
            - string
            - 'null'
          enum:
            - locality
            - area
            - country
            - null
          description: 'How specific region is: locality for a North American rate centre or exchange area, or a place named with its state ("San Francisco, CA"); area for the area a code covers, usually named after its main city or, in North America, its state ("London", "California"); country when region is only the country''s name, as for most mobiles ("France"). A heuristic over where the description came from and its shape, not a measured precision. Null when region is null.'
        local_time:
          type:
            - string
            - 'null'
          format: date-time
          description: 'The local time at the number now (ISO 8601 with its offset), only when every zone in timezones shares the current UTC offset. Worked out on every request, never stored. The zone is where the number was issued, not where the person is.'
        utc_offsets:
          type: array
          items:
            type: string
            example: '-05:00'
          description: "The distinct current UTC offsets across timezones, sorted west to east. More than one means the number's zones disagree right now. Empty when there are no zones."
        in_call_window:
          type:
            - boolean
            - 'null'
          description: 'Only when the request sent call_window: true when the local time in every zone in timezones is inside the window, false when any is outside. Null without a window, without zones, and on an answer served from the suppression list. Clock arithmetic, not compliance advice.'
        did_you_mean:
          type:
            - string
            - 'null'
          description: 'Invalid answers only, when a country was sent: the E.164 form of the one valid reading of the input, the digits with a missing + (447400123456 sent with US) or with a foreign international prefix left in (011 outside North America). A suggestion, never applied: the answer stays invalid and free. Null when no reading or more than one is valid, when the reading lands in a block nobody holds (unless it sits in a range reserved for fiction, which answers valid), or when the input could be a national number with one digit too many or too few.'
        range_seen_by_you:
          type:
            - object
            - 'null'
          description: 'Distinct numbers your account looked up in the same 1,000-number range (the E.164 number without its last three digits) inside the window, this one included; five or more add `range_burst`. Counted for valid single and browser lookups only; null on batch, bulk and monitor rows, test numbers, invalid answers and publishable-key answers.'
          properties:
            distinct_numbers:
              type: integer
              minimum: 1
            window_minutes:
              type: integer
              minimum: 1
        client_velocity:
          type:
            - object
            - 'null'
          description: "Distinct numbers your account looked up from the same client address inside the window, this one included: the client_ip you sent, or the browser visitor's address; an IPv6 address counts by its /64. Null without a client address, on every answer range_seen_by_you does not count, and on publishable-key answers."
          properties:
            distinct_numbers:
              type: integer
              minimum: 1
            window_minutes:
              type: integer
              minimum: 1
        destination_new_for_you:
          type:
            - boolean
            - 'null'
          description: "True when your account has not looked up a valid number in this country in the last 30 days. Null until a week of the account's own lookups has been counted, on every answer range_seen_by_you does not count, and on publishable-key answers. Carries no weight."
        complaints_90d:
          type:
            - integer
            - 'null'
          minimum: 0
          description: "North American numbers only: consumer complaints naming this number as the caller in the FTC's reported-calls data over the last 90 days; 0 when nobody complained. Null outside the NANP or before the first sync. Unverified reports about callers, often spoofed: context, not proof, and not a do-not-call scrub."
        complaints_last_at:
          type:
            - string
            - 'null'
          format: date
          description: 'The date of the latest such complaint.'
        ok_to_send:
          type: boolean
          description: "Your account's SMS policy applied to this answer (GET /api/v1/phone/policy): false when the number is invalid, your suppression list holds it as opted out (unless you switched block_opted_out off), its country or line type is not one you send to, a blocked signal fired, the score is above your ceiling, or a live check you require did not reach the handset. A fraud and deliverability decision, not consent and not a do-not-call scrub."
        blocked_by:
          type:
            - string
            - 'null'
          enum:
            - invalid
            - suppressed
            - opted_out
            - country
            - line_type
            - fictional
            - disposable
            - disposable_stale
            - reported_abuse
            - reported_undelivered
            - reported_abuse_widely
            - premium_rate
            - reserved_block
            - virtual
            - range_burst
            - voip
            - pager
            - digit_pattern
            - numbers_per_client
            - disposable_range
            - international_network
            - shared_cost
            - reported_unwanted_calls
            - risk_score
            - unreachable
            - unconfirmed
            - null
          description: "The first rule that decided a false, in this order: invalid, opted_out, country, line_type, a signal name from risk_signals (in the weights' order), risk_score, then unreachable or unconfirmed; suppressed on a batch, bulk or monitored answer served from your suppression list. opted_out answers a single or browser lookup of a number your suppression list holds as opted out. Null when ok_to_send is true."
        sources:
          type: array
          items:
            $ref: '#/components/schemas/Source'
    PhoneBatchResponse:
      type: object
      properties:
        success:
          type: boolean
        data:
          type: object
          properties:
            results:
              type: array
              description: 'One entry per processed number, in input order.'
              items:
                type: object
                properties:
                  index:
                    type: integer
                    description: "The item's zero-based position in the submitted list. A batch truncated by `stopped_reason` answers only a prefix of the list, so this is what lines an answer up with its submission."
                  input:
                    type: string
                    description: 'The number exactly as it was submitted; `data.e164` is what the plan made of it. An item that named its own country is answered under it, and `data.country` says which.'
                  data:
                    $ref: '#/components/schemas/PhoneResult'
                  meta:
                    type: object
                    properties:
                      credits_used:
                        type: integer
                      cache_hit:
                        type: boolean
                      suppressed:
                        type: boolean
                        description: 'Whether the answer was served from the suppression list.'
                      opted_out:
                        type: boolean
                        description: 'Whether the suppression list holds the number as opted out; such a number is always served suppressed.'
        meta:
          type: object
          properties:
            requested:
              type: integer
            processed:
              type: integer
            credits_used:
              type: integer
            credits_remaining:
              type: integer
            stopped_reason:
              type:
                - string
                - 'null'
              enum:
                - insufficient_credits
                - key_spend_cap
                - null
            request_id:
              type:
                - string
                - 'null'
    PhoneBlockReport:
      type: object
      description: "What a regulator's allocation table says about every block under one prefix. Original allocation only, never who serves a number today."
      properties:
        country:
          type: string
          description: 'ISO 3166-1 alpha-2, uppercased.'
        country_name:
          type:
            - string
            - 'null'
        calling_code:
          type:
            - integer
            - 'null'
        prefix:
          type: string
          description: 'The stored prefix the report covers, digits only and no calling code. A leading trunk prefix in the request is removed: `gb/020` answers `20` and `us/1212` answers `212`. Italy, Poland and Mexico keep every digit.'
        international_prefix:
          type: string
          description: 'The calling code and the prefix, as a number in the range would start.'
        place:
          type:
            - string
            - 'null'
          description: 'The place the range is known by. For a North American area code, a city ("Seattle, WA") where most of the numbering metadata''s sample numbers, or about half of the allocated codes, agree on it, and otherwise the state or province ("Montana"), with its busiest rate centres in `places`. Elsewhere, the place the numbering metadata names for the range, or the exchange area the feed records; a range that serves many towns reads as the first three and how many more.'
        places:
          type: array
          items:
            type: string
          maxItems: 3
          description: 'Up to three rate centres with the most allocated codes under the prefix, as NANPA and the CNAC record them, most first. Filled only when `place` names a state or province rather than a city, and always empty outside the North American tables. A count of allocated codes, never of population or subscribers.'
        timezones:
          type: array
          items:
            type: string
          description: "IANA zone names the range falls in, kept to the zones of the range's own country. Read from up to twelve example numbers spread over the blocks and, in the North American tables, from one number in every allocated central office code as well."
        line_types:
          type: array
          items:
            type: string
          description: 'The line types the numbering plan gives numbers in the range.'
        example:
          type:
            - string
            - 'null'
          description: 'A valid number of the plan inside the range, in international notation. It is a shape, not an assignment: nobody is claimed to hold it.'
        blocks:
          type: object
          description: 'How the blocks under the prefix are split by status.'
          properties:
            total:
              type: integer
            allocated:
              type: integer
            unassigned:
              type: integer
            reserved:
              type: integer
            pooled:
              type:
                - integer
                - 'null'
              description: "Allocated central office codes split into thousands-blocks, which carriers other than the code holder may hold: the holder, and the line type it settles, describe the code, not every number in it (a lookup of a US number reads its own thousands-block's holder where NANPA lists it). Null where the table does not say (outside NANPA and CNAC)."
        assigned:
          type: object
          description: "When the regulator assigned the allocated codes or ranges under the prefix, where its table dates them (NANPA, CNAC, ARCEP). In a pooled code the date is the code's; its thousands-blocks may have been handed out later."
          properties:
            dated:
              type: integer
              description: 'Allocated blocks that carry a date. NANPA dates few codes assigned before 2001.'
            first:
              type:
                - string
                - 'null'
              format: date
            last:
              type:
                - string
                - 'null'
              format: date
              description: 'The most recent assignment: a recent date means new codes were opened in the range.'
        block_length:
          type: integer
          description: 'The digit length most blocks under the prefix are filed at.'
        kinds:
          type: object
          description: 'The share of allocated blocks each kind carries, as a percentage. Null when nothing under the prefix is allocated.'
          properties:
            mobile:
              type:
                - number
                - 'null'
            voip:
              type:
                - number
                - 'null'
            unsettled:
              type:
                - number
                - 'null'
              description: 'Allocated blocks the table does not settle a kind for.'
        holders:
          type: array
          description: 'The ten largest holders of allocated blocks, biggest first.'
          items:
            type: object
            properties:
              holder:
                type: string
                description: 'The name on the allocation, as the regulator spells it.'
              brand:
                type:
                  - string
                  - 'null'
                description: 'The network brand behind the holder, as carrier_brand answers it. Null when the brand map does not know the holder.'
              kind:
                type:
                  - string
                  - 'null'
                description: "The kind most of this holder's blocks carry."
              blocks:
                type: integer
              share:
                type: number
                description: 'Percentage of the allocated blocks under the prefix.'
        brands:
          type: array
          description: 'The ten largest network brands across the allocated blocks, biggest first, grouping holders the brand map puts together (Pacific Bell and New Cingular Wireless are both AT&T). Holders without a brand are left out.'
          items:
            type: object
            properties:
              brand:
                type: string
              blocks:
                type: integer
              share:
                type: number
                description: 'Percentage of the allocated blocks under the prefix.'
        feed:
          type: object
          description: 'The table the report was read from.'
          properties:
            key:
              type: string
            dataset:
              type: string
            version:
              type:
                - string
                - 'null'
              description: 'The date the copy on this server was published.'
            complete:
              type: boolean
              description: 'Whether the table covers the whole plan or only part of it.'
        disposable:
          type:
            - object
            - 'null'
          description: 'What the SMS-receiving lists say about the numbers under the prefix, counted at the time of the call. Null when the list is not installed. Only this endpoint carries it; the public area-code pages do not.'
          properties:
            fresh_listings:
              type: integer
              description: 'Numbers under the prefix whose listing still scores disposable: still listed and seen within the last year (an undated listing counts), or dropped from the lists and seen within the last 90 days.'
            newest_seen_at:
              type:
                - string
                - 'null'
              format: date
              description: 'The newest last-seen date among them.'
            flagged_ranges:
              type: integer
              description: '1,000-number ranges overlapping the prefix (the ranges under it, or the one a longer prefix sits in) that hold ten or more such listings; a number in one that is not listed itself scores disposable_range.'
            version:
              type:
                - string
                - 'null'
              description: "The list's version, as in `sources`."
    PhoneBulkJob:
      type: object
      properties:
        id:
          type: integer
        status:
          type: string
          enum:
            - queued
            - processing
            - completed
            - failed
            - cancelled
        total:
          type: integer
          description: 'Rows in the run.'
        duplicate_count:
          type: integer
          description: 'Input rows that repeated an earlier number, as submitted.'
        processed:
          type: integer
        valid:
          type: integer
          description: 'Processed rows that answered valid true.'
        invalid:
          type: integer
        low:
          type: integer
          description: 'Processed rows with risk_level low.'
        medium:
          type: integer
        high:
          type: integer
        summary:
          allOf:
            -
              $ref: '#/components/schemas/PhoneBulkSummary'
            -
              type:
                - object
                - 'null'
              description: "What the finished run's rows add up to; null until the run settles. The completion webhook does not carry it."
        credits_used:
          type: integer
        stopped_reason:
          type:
            - string
            - 'null'
          enum:
            - insufficient_credits
            - null
        cancel_requested:
          type: boolean
        country:
          type:
            - string
            - 'null'
          description: 'The region every row was parsed in, when one was given.'
        webhook_status:
          type:
            - string
            - 'null'
          enum:
            - pending
            - delivered
            - failed
            - null
          description: "The last delivery's state: `pending` while one has been asked for and has not answered yet, then `delivered` or `failed`. Null before the first one."
        webhook_detail:
          type:
            - string
            - 'null'
          description: 'Why the attempt ended as it did: the HTTP status the endpoint answered with, or the transport failure when it answered at all. The response body is never stored.'
        created_at:
          type: string
          format: date-time
        finished_at:
          type:
            - string
            - 'null'
          format: date-time
    AddressResult:
      type: object
      description: 'The 61 documented fields of an address validation, always in this order, plus the sources consulted. A field whose dataset is not installed answers null, which means "not evaluated" and never "no".'
      properties:
        valid:
          type: boolean
          description: 'Whether the address can stand as written for its country: every part that operator requires is there, the postcode is the right shape, and where a whole directory is installed, the postcode exists.'
        reason:
          type:
            - string
            - 'null'
          enum:
            - empty_address
            - missing_country
            - unknown_country
            - missing_street
            - missing_locality
            - missing_administrative_area
            - missing_postal_code
            - invalid_postal_code
            - postcode_not_found
            - suppressed
            - null
          description: 'Why it cannot stand as written. `suppressed` comes only where your suppression list is honoured (batch, bulk and monitors), for an address on it. Documented one by one at https://spaw.co/docs/reasons.'
        country:
          type:
            - string
            - 'null'
          description: 'ISO 3166-1 alpha-2, as sent.'
        country_name:
          type:
            - string
            - 'null'
          description: 'The English name of the country.'
        continent:
          type:
            - string
            - 'null'
          description: 'Two-letter continent code: AF, AN, AS, EU, NA, OC, SA.'
        is_eu:
          type:
            - boolean
            - 'null'
          description: 'Whether the country is a member of the European Union.'
        is_eea:
          type:
            - boolean
            - 'null'
          description: 'Whether the country is in the European Economic Area.'
        privacy_regime:
          type:
            - string
            - 'null'
          description: 'The comprehensive privacy law that applies where the address is (gdpr, uk_gdpr, ccpa, lgpd, …), settled below the country where a region such as California has its own. Null where none is known.'
        formatted:
          type:
            - string
            - 'null'
          description: 'The address written the way that country writes it, with newlines between lines and the parts it uppercases uppercased. Null for an address that cannot stand as written.'
        formatted_lines:
          type:
            - array
            - 'null'
          items:
            type: string
          description: 'The same rendering as formatted, one line per entry, for a label printer or a form with one field per line. Null when formatted is.'
        formatted_local:
          type:
            - string
            - 'null'
          description: "The address in the country's own script and line order — Japan, China, Taiwan, Hong Kong, Macau and Korea — when the input was written in that script; largest part first, no uppercase rules. Null for Latin-script input and for every other country: transliterating would be inventing."
        organization:
          type:
            - string
            - 'null'
          description: 'The company or institution, as sent or as read out of the written address: a line above a numbered street that carries no number is read as the organisation.'
        building:
          type:
            - string
            - 'null'
          description: 'A building named instead of numbered (Rose Cottage, The Old Rectory, Kemp House), as sent in `building` or as separated from the street line of a written address.'
        street:
          type:
            - string
            - 'null'
          description: 'The street name without its house number.'
        house_number:
          type:
            - string
            - 'null'
          description: 'The house or building number, including a letter or range where it has one (221B, 71-75).'
        unit:
          type:
            - string
            - 'null'
          description: 'A flat, suite, apartment, floor or private-mailbox number found on the address lines. A floor written on a line of its own, before or after the street on its line, or between the house number and the street, is read as the unit rather than the street. Several units written on one address are kept, joined with `, ` in the order written: `5th Floor, Suite 500`.'
        po_box:
          type:
            - string
            - 'null'
          description: 'The box, general-delivery, parcel-locker, CS or TSA line as it would be written back (PO BOX 1234, POSTFACH 10 64 38). A rural route and a military routing line stay in `street`.'
        dependent_locality:
          type:
            - string
            - 'null'
          description: 'A neighbourhood, village or district written below the town.'
        locality:
          type:
            - string
            - 'null'
          description: 'The town or city.'
        administrative_area:
          type:
            - string
            - 'null'
          description: 'The state, province or region, folded to the code that country uses where one exists (California becomes CA).'
        postal_code:
          type:
            - string
            - 'null'
          description: 'The postcode exactly as it was sent; the comparisons behind the other fields fold case and spacing themselves.'
        postal_code_formatted:
          type:
            - string
            - 'null'
          description: 'The postcode written the way its operator prints it — the British space (SW1A 2AA), the Dutch space (1012 NX), the Polish hyphen (00-950), the ZIP+4 hyphen — from the value sent. Null when the postcode is missing or of the wrong shape.'
        postal_code_valid:
          type:
            - boolean
            - 'null'
          description: "Whether the postcode matches the country's published pattern. Null when the country has no postcodes or none was given."
        postal_code_type:
          type:
            - string
            - 'null'
          description: 'What that country calls it: zip, postal, eircode, pin, … Null where the country has none.'
        postcode_status:
          type:
            - string
            - 'null'
          enum:
            - live
            - terminated
            - null
          description: "What the national directory says about the postcode. `terminated` is a code that was withdrawn — a real address that has moved on, not a typo. Null when no directory covers the country; when the code is not listed, which a complete directory also answers with the reason `postcode_not_found` and a partial one never treats as a denial; and for a code outside the directory's scope: in Great Britain a Northern Irish BT postcode, a BFPO number, a BF1 code and a non-geographic BX code answer null (not evaluated), with no town or region comparison, and are never refused as `postcode_not_found`. A ZIP+4 is looked up by its five-digit ZIP, and `postal_code_formatted` keeps all nine digits."
        locality_matches_postcode:
          type:
            - boolean
            - 'null'
          description: "Whether the town written on the address is one of the places the directory publishes for that postcode, as written or under another name the GeoNames list of places gives the same place within 30 km of the postcode. Null when either is unknown or the directory publishes no place for the code. A false is scored only when the town is not listed anywhere in the postcode's wider area either and the directory lists every place a code is written with — see `confirmation.locality`."
        administrative_area_matches_postcode:
          type:
            - boolean
            - 'null'
          description: 'Whether the state, province or region written on the address is the one the directory publishes for that postcode, compared as a code or as a name. Null when either is unknown, and when the value written is a place inside the published region that the directory cannot place the postcode in or out of: an Eircode routing key is filed only under its province, so an Irish county in that province is null and one in another province is false, unless the Property Price Register shows the key reaching that county, which is null too (fourteen keys reach into another province). P14 is the exception: GeoNames files it under Leinster though none of its sales is there, so a Leinster county is false for it. A Dublin district key (D01–D24, D6W) also confirms County Dublin and its own district, except D11 and D15, which reach into Meath and confirm only their own district.'
        address_type:
          type: string
          enum:
            - street
            - po_box
            - private_mailbox
            - general_delivery
            - parcel_locker
            - rural_route
            - military
            - bulk_mail
            - unknown
          description: "What kind of delivery point the address names, from the postal operator's own naming for it."
        address_type_source:
          type:
            - string
            - 'null'
          enum:
            - address_rules
            - postcode_directory
            - provider
            - null
          description: "What settled the type: the operator's naming rules, the postcode directory, or a licensed partner."
        is_po_box:
          type:
            - boolean
            - 'null'
          description: 'Whether the address is a post-office box. Null where no rules are installed for the country.'
        is_private_mailbox:
          type:
            - boolean
            - 'null'
          description: 'Whether the address is a rented mailbox at a mail-receiving agency, either written as one in the way the postal service requires (PMB 1234) or reported as one by a licensed partner. Null when neither said so.'
        exists:
          type:
            - boolean
            - 'null'
          description: 'Whether the building itself is in a national address register. True only on a premise match; false only when the register that answered has complete coverage (register_coverage "complete") and does not carry this one; null otherwise, including a register whose coverage is partial and a register of streets alone, which can never answer about a building.'
        match_level:
          type:
            - string
            - 'null'
          enum:
            - premise
            - range
            - street
            - postcode
            - none
            - null
          description: "How far the address could be followed into the register: to the building, into a published house-number range on the street, to the street, to the postcode, or not at all. Null when no register covers the country, or when the postcode sits outside the register's area (Great Britain's Open Names is not asked about BT, BF or BX codes)."
        register:
          type:
            - string
            - 'null'
          description: 'The register that answered, under the same dataset name it carries in `sources` (us-national-address-database, fr-base-adresse-nationale, …).'
        register_coverage:
          type:
            - string
            - 'null'
          enum:
            - complete
            - partial
            - null
          description: 'How complete that register is for its country, as its publisher describes it, except where Spaw marks a register `partial` because its copy cannot vouch for an address it lacks; the [address data sources page](https://spaw.co/tools/address-checker/sources) says which and why. `partial` is why an address it does not carry answers `exists: null`.'
        premise_key:
          type:
            - string
            - 'null'
          description: 'A 16-character hex digest of the building — country, postcode or town, folded street and house number — the same key your outcome reports and your suppression list are filed under. Two spellings of one address share it, so it is what to join or deduplicate on. It is a key of a place, never of a person. Null when the address names no building.'
        building_use:
          type:
            - string
            - 'null'
          enum:
            - residential
            - commercial
            - mixed
            - industrial
            - institutional
            - agricultural
            - other
            - null
          description: 'What the register that found the building says it is used for, where the register publishes a use. Null when it does not, and on anything short of a premise match. Never inferred.'
        is_multi_unit:
          type:
            - boolean
            - 'null'
          description: 'Whether the register lists several flats or suites at the building. True from two or more unit rows; false only when a register that declares its units lists none; null otherwise — including the US National Address Database, whose contributors list units unevenly.'
        unit_check:
          type:
            - string
            - 'null'
          enum:
            - ok
            - missing
            - unexpected
            - null
          description: 'The unit written on the address against the units the register lists at the building: `missing` when the register lists several and none was written, `unexpected` when a flat or suite is written at a single residence, `ok` when they agree. Null when the register did not find the building, does not say how many units it has, or lists one unit at a non-residential building, where a suite is ordinary. A private-mailbox number never counts as a unit.'
        street_suggestion:
          type:
            - string
            - 'null'
          description: 'The street the register most likely meant, in its own spelling, when the typed street is not in the postcode but one close to it is. Null when the street was found, when nothing is close, or where no street listing is installed.'
        latitude:
          type:
            - number
            - 'null'
          description: 'Where the building stands when the register that found it publishes a position (`location_source: register`, about a metre of precision), otherwise the centroid of the postcode the address is in.'
        longitude:
          type:
            - number
            - 'null'
        location_source:
          type:
            - string
            - 'null'
          enum:
            - register
            - postcode_centroid
            - null
          description: "What the coordinates describe: the building, or the postcode's centroid. Null when there are none."
        companies_registered:
          type:
            - integer
            - 'null'
          description: 'How many companies the open company registers list at the address. Only companies still on a register are counted. Null where no register covers the country.'
        is_mail_drop:
          type:
            - boolean
            - 'null'
          description: 'Whether the address is one a mailbox operator rents boxes at, or carries so many registered companies that it is one in practice. Null when neither dataset covers the country.'
        mail_drop_provider:
          type:
            - string
            - 'null'
          description: 'The operator whose branch list carries the address, or the registered-agent or formation service the address belongs to. Named because that is a fact about the operator, not a verdict about the person using it. Null when the answer came from the company count.'
        mail_drop_source:
          type:
            - string
            - 'null'
          enum:
            - operator_list
            - company_cluster
            - registered_agent
            - null
          description: "Which of the three said so: the operator's own published branches, a cluster of registered companies at one address, or an address a registered-agent or formation service publishes, or that a public listing or filing naming the service carries — where a company being registered is ordinary and a person claiming to live is not."
        deliverability_checked:
          type: boolean
          description: 'Whether the licensed partner answered. False until a partner is enabled, and whenever the lookup did not ask for one.'
        is_deliverable:
          type:
            - boolean
            - 'null'
          description: 'Whether post is actually delivered to the address, per the partner.'
        is_cmra:
          type:
            - boolean
            - 'null'
          description: 'Whether the delivery point is a commercial mail-receiving agency, per the partner.'
        is_vacant:
          type:
            - boolean
            - 'null'
          description: 'Whether the delivery point is recorded as vacant, per the partner.'
        is_residential:
          type:
            - boolean
            - 'null'
          description: 'Whether the delivery point is residential rather than a business, per the partner.'
        confirmation:
          type:
            - object
            - 'null'
          description: 'What stood behind each part of the address. `unconfirmed` means a dataset that could have confirmed the part did not; null means nothing could check it. Null as a whole for an address that cannot stand as written.'
          properties:
            postal_code:
              type:
                - string
                - 'null'
              enum:
                - postcode_directory
                - register
                - pattern
                - unconfirmed
                - null
              description: "The directory lists the code; a register knows it; or only the country's pattern was met. A code outside the directory's scope (BT, BFPO, BF1 or BX in Great Britain) answers `pattern`, never `postcode_directory` or `unconfirmed`: Open Names covers Great Britain only and is not asked about BT, BF or BX codes either."
            street:
              type:
                - string
                - 'null'
              enum:
                - register
                - company_register
                - mail_drop_list
                - unconfirmed
                - null
              description: "A register lists the street (or a range on it, or the building itself), a company register or a mailbox operator's list names the building, or nothing did."
            house_number:
              type:
                - string
                - 'null'
              enum:
                - register
                - range
                - company_register
                - mail_drop_list
                - unconfirmed
                - null
              description: "The building is in a register, inside a published number range, named by a company register or an operator's list, or unconfirmed."
            locality:
              type:
                - string
                - 'null'
              enum:
                - postcode_directory
                - postcode_area
                - unconfirmed
                - null
              description: "The town is one the directory publishes for the postcode, as written or under another name GeoNames gives the same place (`postcode_directory`); or one it lists elsewhere in the postcode's area, or, where the postcode's region is a single city (Mexico City, Delhi, Jakarta, Bangkok, Bogotá, Moscow, Saint Petersburg, Bucharest, Seoul, Korea's six metropolitan cities and Sejong, and Tokyo for a code filed under one of its wards), that region's own name, as Distrito Federal is for 06000 and Tokyo for 100-0005 (`postcode_area`, reported and never scored); or none of these (`unconfirmed`). Any other region's name is not the postcode's area."
            administrative_area:
              type:
                - string
                - 'null'
              enum:
                - postcode_directory
                - unconfirmed
                - null
              description: 'The directory publishes the region for the postcode, under any of its names and codes (`postcode_directory`), or it does not (`unconfirmed`). An Irish county inside the province an Eircode routing key is filed under answers null, because the directory can neither confirm nor refuse it. P14 is the exception: GeoNames files it under Leinster though none of its sales is there, so a Leinster county is refused for it (`unconfirmed`). County Dublin for a Dublin district key other than D11 and D15 is confirmed.'
        changes:
          type: array
          description: 'What the parser and the classifier moved or set aside on the way from the input to the parts, so a normalisation is never silent. Empty when nothing was changed.'
          items:
            type: string
            enum:
              - country_line_removed
              - care_of_removed
              - building_separated
              - organization_separated
              - administrative_area_folded
              - box_line_moved
              - private_mailbox_moved_to_unit
        looks_placeholder:
          type:
            - boolean
            - 'null'
          description: 'Whether the street or the town is what somebody types to get past a form rather than a place: a test word, a keyboard run, a refusal, a name with no vowel, one character repeated. Whole names only — a real street that merely contains one of the words is never flagged. Null when there is no street and no town to judge.'
        seen_by_you:
          type:
            - object
            - 'null'
          description: "How many times your own account looked this address up inside the window before this call, and when the first of those was — kept under the premise digest, never the address, and never anybody else's lookups. Null on the guest demo and for test addresses."
          properties:
            count:
              type: integer
            first_seen_at:
              type:
                - string
                - 'null'
              format: date-time
            window_days:
              type: integer
        risk_score:
          type:
            - integer
            - 'null'
          minimum: 0
          maximum: 100
          description: 'The sum of the weights in risk_signals, capped at 100. Null for an address that cannot stand as written.'
        risk_level:
          type:
            - string
            - 'null'
          enum:
            - low
            - medium
            - high
            - null
          description: 'high at 60 and above, medium at 30 and above.'
        risk_signals:
          type: array
          description: 'The signals behind the score, each with the weight applied and the dataset that produced it, so the arithmetic can be repeated from the same response.'
          items:
            type: object
            properties:
              signal:
                type: string
                enum:
                  - undeliverable
                  - private_mailbox
                  - placeholder
                  - mail_drop
                  - reported_returned
                  - vacant
                  - general_delivery
                  - po_box
                  - unit_unexpected
                  - terminated_postcode
                  - locality_mismatch
                  - region_mismatch
                  - not_in_register
                  - unit_missing
                  - street_not_found
                  - number_out_of_range
                  - bulk_mail
              weight:
                type: integer
              dataset:
                type: string
        ok_to_ship:
          type: boolean
          description: "The account's shipping policy applied to this answer: false when a blocked signal fired, the score is above the policy's ceiling, a register did not confirm a building the policy requires confirmed, or the address cannot stand as written."
        blocked_by:
          type:
            - string
            - 'null'
          enum:
            - invalid
            - undeliverable
            - private_mailbox
            - placeholder
            - mail_drop
            - reported_returned
            - vacant
            - general_delivery
            - po_box
            - unit_unexpected
            - terminated_postcode
            - locality_mismatch
            - region_mismatch
            - not_in_register
            - unit_missing
            - street_not_found
            - number_out_of_range
            - bulk_mail
            - risk_score
            - unconfirmed
            - suppressed
            - null
          description: "What decided a false ok_to_ship, in the order the policy is applied: invalid, then the first blocked signal in the weights' order, then risk_score, then unconfirmed. `suppressed` comes only where your suppression list is honoured — batch, bulk and monitors — for an address on it, answered from the list without a lookup; a single lookup never answers it. Null when shipping is fine."
        sources:
          type: array
          items:
            $ref: '#/components/schemas/Source'
    EntityMonitor:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        cadence:
          type: string
          enum:
            - weekly
            - monthly
        identifier_count:
          type: integer
        next_run_at:
          type: string
          format: date-time
        last_run_at:
          type:
            - string
            - 'null'
          format: date-time
        webhook_url:
          type:
            - string
            - 'null'
          description: 'Where a reported run is POSTed, if this monitor has a webhook.'
        webhook_events:
          type: string
          enum:
            - changes
            - every_run
          description: 'Which runs are posted: only the ones that found something new, or every one.'
        webhook_status:
          type:
            - string
            - 'null'
          enum:
            - delivered
            - failed
            - null
          description: 'How the last delivery went; null before the first one.'
        webhook_detail:
          type:
            - string
            - 'null'
          description: 'What the endpoint answered — the HTTP status, or the transport failure when it answered nothing. The response body is never stored.'
        prunes_after_days:
          type: integer
          description: 'How long this list is kept if nobody runs it. An active monitor is never pruned; one that has stopped being run is deleted with everything it stored.'
        last_summary:
          $ref: '#/components/schemas/EntityMonitorSummary'
        created_at:
          type:
            - string
            - 'null'
          format: date-time
    EntityMonitorSummary:
      type:
        - object
        - 'null'
      description: 'What the last run found.'
      properties:
        total:
          type: integer
        ok:
          type: integer
        left_register:
          type: integer
          description: 'Identifiers a register carried before and does not now. For the UK this is what a dissolution looks like: a company struck off is removed from the free company file rather than marked dissolved.'
        dissolved:
          type: integer
        inactive:
          type: integer
        registration_lapsed:
          type: integer
        registers_disagree:
          type: integer
        unverifiable:
          type: integer
          description: 'Identifiers no register on this server could answer for. Never treated as though the entity had gone.'
        changed:
          type: integer
        newly_flagged:
          type: integer
          description: 'Entries that were fine last run and are not now. These are what the email reports.'
        credits_used:
          type: integer
        stopped_reason:
          type:
            - string
            - 'null'
          enum:
            - insufficient_credits
            - null
    EntityMonitorResult:
      type: object
      properties:
        status:
          type: string
          enum:
            - ok
            - left_register
            - dissolved
            - inactive
            - registration_lapsed
            - registers_disagree
            - unverifiable
        flags:
          type: array
          items:
            type: string
          description: 'Every condition that fired, in the order the status is picked from them.'
        reason:
          type:
            - string
            - 'null'
          description: 'Why no register answered, when status is unverifiable.'
        name:
          type:
            - string
            - 'null'
        registration_status:
          type:
            - string
            - 'null'
        changed:
          type: boolean
          description: 'Whether the status differs from the previous run. False on the first run and on a newly added identifier.'
    EntityResult:
      type: object
      description: 'One business identifier as the registers hold it. No registered address is carried, deliberately.'
      properties:
        identifier:
          type: string
          description: 'The identifier as the register writes it: a LEI uppercased, a company number zero-padded to eight.'
        identifier_type:
          type: string
          enum:
            - lei
            - company_number
        found:
          type: boolean
          description: 'Whether a register carried it. False is free.'
        reason:
          type:
            - string
            - 'null'
          enum:
            - unknown_identifier
            - malformed_identifier
            - unsupported_register
            - register_unavailable
            - null
          description: 'Why not, when `found` is false. Documented at https://spaw.co/docs/reasons.'
        name:
          type:
            - string
            - 'null'
          description: 'The legal name the register carries.'
        status:
          type:
            - string
            - 'null'
          enum:
            - active
            - inactive
            - dissolved
            - unknown
            - null
          description: 'Whether the entity is still live, folded to one word.'
        status_detail:
          type:
            - string
            - 'null'
          description: "The register's own wording, which distinguishes a strike-off from a liquidation."
        country:
          type:
            - string
            - 'null'
          maxLength: 2
          description: 'The country the entity is registered in.'
        jurisdiction:
          type:
            - string
            - 'null'
          description: 'The jurisdiction of formation, which can be a subdivision (US-DE).'
        category:
          type:
            - string
            - 'null'
          description: "The register's entity category (GENERAL, FUND, BRANCH, SOLE_PROPRIETOR)."
        legal_form:
          type:
            - string
            - 'null'
          description: 'The ISO 20275 entity legal form code, where the register carries one.'
        registered_at:
          type:
            - string
            - 'null'
          format: date
          description: 'When the entity was formed or incorporated.'
        record_updated_at:
          type:
            - string
            - 'null'
          format: date
          description: 'When the register last updated this record.'
        checksum_valid:
          type:
            - boolean
            - 'null'
          description: 'LEI only: whether the ISO 7064 MOD 97-10 check digit is right. A typing check, not proof the identifier was issued. Null for identifiers that carry no check digit.'
        registration_status:
          type:
            - string
            - 'null'
          description: 'LEI only: ISSUED, LAPSED, RETIRED, ANNULLED, MERGED or DUPLICATE. An entity can be alive while its LEI is not.'
        next_renewal_at:
          type:
            - string
            - 'null'
          format: date
          description: 'LEI only: when the registration was last due for renewal.'
        other_register:
          type:
            - string
            - 'null'
          description: 'The national register this record names, when Spaw also holds it.'
        other_identifier:
          type:
            - string
            - 'null'
          description: "This entity's identifier in that register."
        flags:
          type: array
          items:
            type: string
          description: 'What the registers disagree about, or what one of them warns. Documented at https://spaw.co/docs/reasons.'
        agreements:
          type: array
          description: 'Each cross-register comparison with both sides quoted. Empty when there is no counterpart record.'
          items:
            type: object
            properties:
              field:
                type: string
                enum:
                  - name
                  - status
              lei:
                description: 'The value the LEI record carries.'
              national_register:
                description: 'The value the national register carries.'
              result:
                type: string
                enum:
                  - agree
                  - differ
        sources:
          type: array
          description: 'Every register behind the answer, with the date of the copy this server holds.'
          items:
            type: object
            properties:
              dataset:
                type: string
              version:
                type:
                  - string
                  - 'null'
    EntityBatchResponse:
      type: object
      properties:
        success:
          type: boolean
        data:
          type: object
          properties:
            results:
              type: array
              description: 'One entry per processed identifier, in input order.'
              items:
                type: object
                properties:
                  index:
                    type: integer
                    description: "The item's zero-based position in the submitted list. A batch truncated by `stopped_reason` answers only a prefix of the list, so this is what lines an answer up with its submission."
                  input:
                    type: string
                    description: "The identifier exactly as it was submitted; the answer carries the register's own spelling of it."
                  data:
                    $ref: '#/components/schemas/EntityResult'
                  meta:
                    type: object
                    properties:
                      credits_used:
                        type: integer
                      cache_hit:
                        type: boolean
        meta:
          type: object
          properties:
            requested:
              type: integer
            processed:
              type: integer
            found:
              type: integer
              description: 'How many of the processed identifiers a register carried. This product bills on `found`, so it is the count the charge is drawn from, less any 7-day repeat.'
            credits_used:
              type: integer
            credits_remaining:
              type: integer
            stopped_reason:
              type:
                - string
                - 'null'
              enum:
                - insufficient_credits
                - key_spend_cap
                - null
            request_id:
              type:
                - string
                - 'null'
    SignalMeta:
      type: object
      description: 'What one signal inside a consistency call cost and where it came from. The account balance belongs to the call as a whole, so it is not repeated here.'
      properties:
        credits_used:
          type: integer
          description: 'Credits this signal cost (0 or 1).'
        cache_hit:
          type: boolean
          description: 'Whether the answer was a free 7-day repeat.'
        suppressed:
          type: boolean
          description: 'Present on the email, phone and address signals.'
        refreshed:
          type: boolean
          description: 'Present on the email signal.'
        ip_lookups_prepaid:
          type: integer
          description: 'Present on the IP signal: the IP lookups the account holds prepaid after it. One credit pays for five answered IP lookups, so the IP signal usually costs 0; see IpMeta.'
        fair_use_billed:
          type: boolean
          description: "Present on the email signal: true when the email answer is an undeliverable one that spent a mailbox probe and was billed 1 credit past the account's email fair use; see EmailMeta."
    ConsistencyComparison:
      type: object
      description: 'One check between two signals, with both sides quoted so the working is visible.'
      properties:
        comparison:
          type: string
          description: 'Which check this is.'
          enum:
            - phone_address_country
            - ip_address_country
            - ip_phone_country
            - ip_address_distance
            - email_ip_disposable_datacenter
            - email_ip_disposable_anonymous
            - email_address_free_provider_mail_drop
            - email_phone_disposable
            - phone_address_region
            - entity_address_country
            - entity_address_organization
        left:
          type: object
          description: 'The first side of the comparison.'
          properties:
            signal:
              type: string
              enum:
                - email
                - phone
                - ip
                - address
                - entity
            field:
              type: string
              description: "The field on that signal's own block."
            value:
              description: 'The value as that block reports it.'
        right:
          type: object
          description: 'The second side of the comparison.'
          properties:
            signal:
              type: string
              enum:
                - email
                - phone
                - ip
                - address
                - entity
            field:
              type: string
            value: {}
        result:
          type: string
          enum:
            - agree
            - differ
            - not_compared
          description: '`agree` when the values matched or the pairing held, `differ` when two known values disagreed, `not_compared` when nothing was settled.'
        withheld:
          type:
            - string
            - 'null'
          enum:
            - missing_value
            - low_confidence
            - null
          description: 'Why nothing was settled: `missing_value` when a side was null, `low_confidence` when the IP speaks for its operator rather than for a person.'
        flag:
          type:
            - string
            - 'null'
          description: 'The flag this comparison raised, if any.'
        distance_km:
          type:
            - number
            - 'null'
          description: "Filled only on `ip_address_distance`, as evidence. No threshold is applied and no flag is ever raised from it — nor from `phone_address_region`, which compares the region a North American number was issued in with the address's own and reports the difference alone, because people keep a number when they move."
    ConsistencyResult:
      type: object
      description: 'The free analysis across the signals that answered. No score, no risk level, no verdict.'
      properties:
        signals_compared:
          type: array
          items:
            type: string
            enum:
              - email
              - phone
              - ip
              - address
              - entity
          description: 'The signals that answered and were available to compare.'
        country_agreement:
          type:
            - string
            - 'null'
          enum:
            - agree
            - differ
            - null
          description: "The roll-up over the three country comparisons between the person's own signals. `null` when none of them could be made — never silently `agree`. The entity's country of registration is deliberately not one of them."
        flags:
          type: array
          items:
            type: string
          description: 'Every flag raised, in comparison order. Documented at https://spaw.co/docs/reasons.'
        comparisons:
          type: array
          items:
            $ref: '#/components/schemas/ConsistencyComparison'
          description: 'Every comparison that was possible, including the ones withheld.'
    ConsistencyBulkJob:
      type: object
      description: |
        A queued cross-signal run. The three row counts say what the comparison did and are not verdicts about anybody: `flagged` raised at least one flag, `unflagged` had at least two signals answer and none of them disagree, and `uncompared` had fewer than two answer, so there was nothing to compare.
      properties:
        id:
          type: integer
        status:
          type: string
          enum:
            - queued
            - processing
            - completed
            - failed
            - cancelled
        total:
          type: integer
          description: 'Rows in the run.'
        duplicate_count:
          type: integer
          description: 'Input rows whose four signals ALL repeat a row already in the list. Repeats are free per lookup rather than per row, so a row that repeats only one of its signals is not one of these.'
        processed:
          type: integer
        flagged:
          type: integer
          description: 'Processed rows the comparison raised at least one flag on.'
        unflagged:
          type: integer
          description: 'Processed rows where at least two signals answered and none of them disagreed.'
        uncompared:
          type: integer
          description: 'Processed rows where fewer than two signals answered, so there was nothing to compare.'
        credits_used:
          type: integer
          description: 'The sum of the legs the run actually ran. The comparison is free.'
        stopped_reason:
          type:
            - string
            - 'null'
          enum:
            - insufficient_credits
            - null
        cancel_requested:
          type: boolean
        country:
          type:
            - string
            - 'null'
          description: 'The country every row that named none was measured against, for both the phone leg and the address leg.'
        webhook_status:
          type:
            - string
            - 'null'
          enum:
            - pending
            - delivered
            - failed
            - null
          description: "The last delivery's state: `pending` while one has been asked for and has not answered yet, then `delivered` or `failed`. Null before the first one."
        webhook_detail:
          type:
            - string
            - 'null'
          description: 'Why the attempt ended as it did: the HTTP status the endpoint answered with, or the transport failure when it answered at all. The response body is never stored.'
        created_at:
          type: string
          format: date-time
        finished_at:
          type:
            - string
            - 'null'
          format: date-time
    AddressBatchResponse:
      type: object
      properties:
        success:
          type: boolean
        data:
          type: object
          properties:
            results:
              type: array
              description: 'One entry per processed address, in input order.'
              items:
                type: object
                properties:
                  index:
                    type: integer
                    description: "The item's zero-based position in the submitted list. A batch truncated by `stopped_reason` answers only a prefix of the list, so this is what lines an answer up with its submission."
                  input:
                    type: object
                    additionalProperties:
                      type: string
                    description: 'The address as it was submitted, echoed back rather than parsed: `address` for an item sent as a written line, the separated fields for one sent as an object, and only the parts that were actually given. `country` is the one the item was measured against — its own when it named one, the list-level one otherwise.'
                  data:
                    $ref: '#/components/schemas/AddressResult'
                  meta:
                    type: object
                    properties:
                      credits_used:
                        type: integer
                      cache_hit:
                        type: boolean
        meta:
          type: object
          properties:
            requested:
              type: integer
            processed:
              type: integer
            credits_used:
              type: integer
            credits_remaining:
              type: integer
            stopped_reason:
              type:
                - string
                - 'null'
              enum:
                - insufficient_credits
                - key_spend_cap
                - null
            request_id:
              type:
                - string
                - 'null'
    AddressListResponse:
      type: object
      properties:
        success:
          type: boolean
        data:
          type: object
          properties:
            postcode:
              type: string
              description: 'The postcode as it was asked for.'
            country:
              type: string
              description: 'ISO 3166-1 alpha-2.'
            available:
              type: boolean
              const: true
              description: "Always true here: a country with neither a licensed partner nor an open register's street listing answers 501 ADDRESS_LIST_UNAVAILABLE instead."
            addresses:
              type: array
              description: "The addresses at the postcode, at most 100 — in the partner's own order, or, from an open register, its streets sorted by name."
              items:
                type: object
                properties:
                  address_line1:
                    type:
                      - string
                      - 'null'
                  address_line2:
                    type:
                      - string
                      - 'null'
                  organization:
                    type:
                      - string
                      - 'null'
                  street:
                    type:
                      - string
                      - 'null'
                  house_number:
                    type:
                      - string
                      - 'null'
                  unit:
                    type:
                      - string
                      - 'null'
                  locality:
                    type:
                      - string
                      - 'null'
                  administrative_area:
                    type:
                      - string
                      - 'null'
                  postal_code:
                    type:
                      - string
                      - 'null'
                    description: 'The postcode the address is filed under. From an open register it is the postcode as asked for, except that a ZIP+4 lists its whole ZIP and carries the ZIP.'
                  country:
                    type:
                      - string
                      - 'null'
                  identifier:
                    type:
                      - string
                      - 'null'
                    description: "The partner's own key for the delivery point, passed through unchanged so it can be quoted back to them."
            count:
              type: integer
              description: 'How many addresses the answer carries.'
        meta:
          type: object
          description: "A licensed partner's list has no repeat marker, so its meta carries no cache_hit. An open register's list carries cache_hit: it is free for seven days after it was first charged for the same postcode, and every spelling of one ZIP counts as that ZIP."
          properties:
            credits_used:
              type: integer
            credits_remaining:
              type: integer
            cache_hit:
              type: boolean
              description: "Only on an open register's list: true when the answer was free under the seven-day repeat marker."
            request_id:
              type:
                - string
                - 'null'
    AddressBulkJob:
      type: object
      properties:
        id:
          type: integer
        status:
          type: string
          enum:
            - queued
            - processing
            - completed
            - failed
            - cancelled
        total:
          type: integer
          description: 'Rows in the run.'
        duplicate_count:
          type: integer
          description: 'Input rows repeating an address already in the list, as the pipeline folds it.'
        processed:
          type: integer
        valid:
          type: integer
          description: 'Processed rows that answered valid true.'
        invalid:
          type: integer
        low:
          type: integer
          description: 'Processed rows with risk_level low.'
        medium:
          type: integer
        high:
          type: integer
        summary:
          allOf:
            -
              $ref: '#/components/schemas/AddressBulkSummary'
            -
              type:
                - object
                - 'null'
              description: "What the finished run's rows add up to; null until the run has settled. The completion webhook body does not carry it."
        credits_used:
          type: integer
        stopped_reason:
          type:
            - string
            - 'null'
          enum:
            - insufficient_credits
            - null
        cancel_requested:
          type: boolean
        country:
          type:
            - string
            - 'null'
          description: 'The country every row that named none was measured against.'
        deliverability:
          type: boolean
          description: 'Whether every row asked the licensed partner whether post arrives there.'
        webhook_status:
          type:
            - string
            - 'null'
          enum:
            - pending
            - delivered
            - failed
            - null
          description: "The last delivery's state: `pending` while one has been asked for and has not answered yet, then `delivered` or `failed`. Null before the first one."
        webhook_detail:
          type:
            - string
            - 'null'
          description: 'Why the attempt ended as it did: the HTTP status the endpoint answered with, or the transport failure when it answered at all. The response body is never stored.'
        created_at:
          type: string
          format: date-time
        finished_at:
          type:
            - string
            - 'null'
          format: date-time
    PhoneBulkSummary:
      type: object
      description: "What a finished bulk phone run's rows add up to, built from the same cells the result file holds so it can never disagree with the download."
      properties:
        rows:
          type: integer
          description: 'Rows written.'
        duplicates:
          type: integer
          description: 'Rows marked is_duplicate: an earlier row of the list held the same number in any spelling.'
        verdicts:
          type: object
          properties:
            valid:
              type: integer
            invalid:
              type: integer
        reasons:
          type: object
          additionalProperties:
            type: integer
          description: 'How many rows answered each reason, alphabetical. An empty map is encoded as [].'
        risk:
          type: object
          properties:
            low:
              type: integer
            medium:
              type: integer
            high:
              type: integer
        line_types:
          type: object
          additionalProperties:
            type: integer
          description: 'Valid rows per line_type, alphabetical. Most North American numbers answer fixed_line_or_mobile, because mobiles and landlines share blocks there. An empty map is encoded as [].'
        line_type_sources:
          type: object
          additionalProperties:
            type: integer
          description: 'Valid rows per line_type_source: numbering_plan, or block_allocation where the block holder settled the type. An empty map is encoded as [].'
        countries:
          type: array
          description: 'The ten most frequent countries, most rows first.'
          items:
            type: object
            properties:
              country:
                type: string
              rows:
                type: integer
        carriers:
          type: array
          description: 'The ten block holders most rows belong to, most rows first. Like `carrier`, the holder the numbers were allocated to, not whoever serves them after a port.'
          items:
            type: object
            properties:
              carrier:
                type: string
              rows:
                type: integer
        signals:
          type: object
          additionalProperties:
            type: integer
          description: 'How many rows carried each risk signal, one key per published weight.'
        flags:
          type: object
          description: 'Rows answering true for is_fictional, is_virtual and is_disposable.'
          properties:
            fictional:
              type: integer
            virtual:
              type: integer
            disposable:
              type: integer
        suppressed:
          type: integer
          description: 'Rows your suppression list answered.'
        live_check:
          type:
            - object
            - 'null'
          description: 'What the live check found: rows it answered for, and of those how many were reachable, unreachable and ported. Null on a run that did not ask for it.'
          properties:
            checked:
              type: integer
            reachable:
              type: integer
            unreachable:
              type: integer
            ported:
              type: integer
        blocked:
          type: integer
          description: 'Rows your phone policy answered ok_to_send false for — the `blocked` download.'
        blocked_by:
          type: object
          additionalProperties:
            type: integer
          description: 'Those rows per blocked_by value, alphabetical. An empty map is encoded as [].'
    AddressBulkSummary:
      type: object
      description: "What a finished bulk address run's rows add up to, built from the same cells the result file holds so it can never disagree with the download."
      properties:
        rows:
          type: integer
          description: 'Rows written.'
        duplicates:
          type: integer
          description: 'Input rows that repeated an address already in the list, as the pipeline folds it.'
        verdicts:
          type: object
          properties:
            valid:
              type: integer
            invalid:
              type: integer
        reasons:
          type: object
          additionalProperties:
            type: integer
          description: 'How many rows answered each reason for not standing as written, alphabetical. An empty map is encoded as [].'
        risk:
          type: object
          properties:
            low:
              type: integer
            medium:
              type: integer
            high:
              type: integer
        countries:
          type: array
          description: 'The ten most frequent countries, most rows first.'
          items:
            type: object
            properties:
              country:
                type: string
              rows:
                type: integer
        types:
          type: object
          additionalProperties:
            type: integer
          description: 'How many rows named each kind of delivery point (street, po_box, private_mailbox, …), alphabetical. An empty map is encoded as [].'
        matches:
          type: object
          description: 'How far the registers followed the rows that stand as written; `unchecked` counts rows no register covered.'
          properties:
            premise:
              type: integer
            range:
              type: integer
            street:
              type: integer
            postcode:
              type: integer
            none:
              type: integer
            unchecked:
              type: integer
        mail_drops:
          type: array
          description: 'The ten mailbox operators or registered agents most rows stood at, most rows first.'
          items:
            type: object
            properties:
              provider:
                type: string
              rows:
                type: integer
        signals:
          type: object
          additionalProperties:
            type: integer
          description: 'How many rows carried each risk signal, one key per published weight.'
        placeholders:
          type: integer
          description: 'Rows whose street or town read as a placeholder.'
        blocked:
          type: integer
          description: "Rows the account's own shipping policy answered ok_to_ship false for."
    AddressFeedbackItem:
      type: object
      required:
        - outcome
      properties:
        address:
          type:
            - string
            - 'null'
          maxLength: 500
          description: 'The whole address written as it would be on an envelope. Required unless address_line1 is sent.'
        address_line1:
          type:
            - string
            - 'null'
          maxLength: 255
          description: 'The street line, when the address arrives separated. Required unless address is sent.'
        address_line2:
          type:
            - string
            - 'null'
          maxLength: 255
        organization:
          type:
            - string
            - 'null'
          maxLength: 200
        building:
          type:
            - string
            - 'null'
          maxLength: 100
        dependent_locality:
          type:
            - string
            - 'null'
          maxLength: 100
        locality:
          type:
            - string
            - 'null'
          maxLength: 100
        administrative_area:
          type:
            - string
            - 'null'
          maxLength: 100
        postal_code:
          type:
            - string
            - 'null'
          maxLength: 32
        po_box:
          type:
            - string
            - 'null'
          maxLength: 64
        country:
          type:
            - string
            - 'null'
          maxLength: 2
          description: "This item's own country; overrides the request-level one."
        outcome:
          type: string
          enum:
            - delivered
            - returned
            - moved
            - refused
        occurred_at:
          type:
            - string
            - 'null'
          format: date-time
          description: 'When it happened. Defaults to now. A time more than five minutes in the future is refused; one inside that margin is recorded as now.'
    AddressFeedbackSummary:
      type: object
      properties:
        window_days:
          type: integer
        total:
          type: integer
        addresses:
          type: integer
          description: 'Distinct addresses reported on inside the window.'
        outcomes:
          type: object
          properties:
            delivered:
              type: integer
            returned:
              type: integer
            moved:
              type: integer
            refused:
              type: integer
    AddressSuppression:
      type: object
      properties:
        id:
          type: integer
        address:
          type: string
          description: 'The address as it was submitted, on one line.'
        reason:
          type: string
          enum:
            - returned
            - moved
            - refused
            - imported
          description: "What put it on the list: the account's own report, or an import."
        source:
          type: string
          enum:
            - auto
            - feedback
            - imported
        added_at:
          type: string
          format: date-time
    AddressMonitor:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        cadence:
          type: string
          enum:
            - weekly
            - monthly
        address_count:
          type: integer
          description: 'Addresses in the list, up to 500.'
        country:
          type:
            - string
            - 'null'
          description: 'The country the entries that name none are measured against.'
        deliverability:
          type: boolean
          description: 'Whether every run asks the licensed partner whether post arrives.'
        next_run_at:
          type: string
          format: date-time
        last_run_at:
          type:
            - string
            - 'null'
          format: date-time
          description: 'When the last run finished; null before the first run.'
        last_summary:
          allOf:
            -
              $ref: '#/components/schemas/AddressMonitorSummary'
            -
              type:
                - object
                - 'null'
              description: "The last run's totals; null until the first run finishes."
        webhook_url:
          type:
            - string
            - 'null'
          description: 'Where a reported run is POSTed, if this monitor has a webhook.'
        webhook_events:
          type: string
          enum:
            - changes
            - every_run
          description: 'Which runs are posted: only the ones that found something new, or every one.'
        webhook_status:
          type:
            - string
            - 'null'
          enum:
            - delivered
            - failed
            - null
          description: 'How the last delivery went; null before the first one.'
        webhook_detail:
          type:
            - string
            - 'null'
          description: 'What the endpoint answered — the HTTP status, or the transport failure when it answered nothing. The response body is never stored.'
        prunes_after_days:
          type: integer
          description: 'How long this list is kept if nobody runs it. A monitor in active use is never pruned; one that has stopped being run is deleted along with the identifiers and results it stored.'
        created_at:
          type: string
          format: date-time
    AddressMonitorSummary:
      type: object
      properties:
        total:
          type: integer
        ok:
          type: integer
          description: 'Addresses that stand as written and fired no condition.'
        invalid:
          type: integer
          description: 'Addresses that no longer stand as written.'
        not_in_register:
          type: integer
          description: 'Addresses a register whose coverage is complete no longer carries.'
        terminated_postcode:
          type: integer
          description: 'Addresses whose postcode the operator has retired.'
        mail_drop:
          type: integer
          description: 'Addresses a mailbox operator publishes as a branch, or that carry a cluster of registered companies.'
        high_risk:
          type: integer
          description: 'Addresses scoring 60 or above.'
        unverifiable:
          type: integer
          description: 'Valid addresses no installed dataset could say anything about: no register, no postcode directory, no company register for the country. Counted apart so you can see how much of the list nobody can check.'
        changed:
          type: integer
          description: 'Addresses whose status differs from the previous run.'
        newly_flagged:
          type: integer
          description: 'Addresses that fired no condition in the previous run and fire one now; these are the ones the alert email lists.'
        credits_used:
          type: integer
        stopped_reason:
          type:
            - string
            - 'null'
          enum:
            - insufficient_credits
            - null
          description: 'Set when the run stopped before the end of the list.'
    AddressMonitorResult:
      type: object
      properties:
        status:
          type: string
          enum:
            - ok
            - invalid
            - not_in_register
            - terminated_postcode
            - mail_drop
            - high_risk
            - unverifiable
          description: 'The first condition that fired, else unverifiable or ok.'
        flags:
          type: array
          items:
            type: string
            enum:
              - invalid
              - not_in_register
              - terminated_postcode
              - mail_drop
              - high_risk
          description: 'Every condition that fired; empty for an ok or unverifiable address.'
        reason:
          type:
            - string
            - 'null'
          description: 'Why the address does not stand as written, when invalid fired.'
        exists:
          type:
            - boolean
            - 'null'
          description: 'Whether the national register carries the address. Null when no register covers the country, when the register that answered is one whose coverage is partial and does not carry the address, or when it lists streets alone.'
        postcode_status:
          type:
            - string
            - 'null'
          enum:
            - live
            - terminated
            - null
        companies_registered:
          type:
            - integer
            - 'null'
          description: 'Companies registered at the address in the open registers.'
        is_mail_drop:
          type:
            - boolean
            - 'null'
        risk_score:
          type:
            - integer
            - 'null'
        risk_level:
          type:
            - string
            - 'null'
          enum:
            - low
            - medium
            - high
            - null
        changed:
          type: boolean
          description: 'Whether the status differs from the previous run; false on the first run.'
    IpResult:
      type: object
      description: 'The 62 fields of an IP answer, always in this order, plus sources. The threat flags are tri-state: false is a checked negative, null means no installed feed covers the address family.'
      properties:
        ip:
          type: string
          description: 'The canonical form of the address.'
        version:
          type: integer
          enum:
            - 4
            - 6
        reason:
          type:
            - string
            - 'null'
          enum:
            - reserved_range
            - null
          description: 'Set when the address is in a reserved or private range.'
        network:
          type:
            - string
            - 'null'
          description: 'The CIDR prefix the location answer covers, e.g. 8.8.8.0/24.'
        continent:
          type:
            - string
            - 'null'
          description: 'Two-letter continent code: AF, AN, AS, EU, NA, OC, SA.'
        country:
          type:
            - string
            - 'null'
          description: 'ISO 3166-1 alpha-2.'
        country_name:
          type:
            - string
            - 'null'
          description: 'The English name of the country.'
        is_eu:
          type:
            - boolean
            - 'null'
          description: 'Whether the country is a member of the European Union. Null when the country is unknown.'
        is_eea:
          type:
            - boolean
            - 'null'
          description: 'Whether the country is in the European Economic Area (the EU plus Iceland, Liechtenstein and Norway), where the GDPR applies directly. Null when the country is unknown.'
        privacy_regime:
          type:
            - string
            - 'null'
          description: 'The comprehensive privacy law where the address is, as a lowercase code: the region first where a state or province has its own law (California ccpa, Quebec law25), otherwise the country (gdpr across the EU and EEA, uk_gdpr, fadp, lgpd, pipeda, appi, pipl, popia, …). Null means no comprehensive law is known for the place, never that there is none; read the code together with the country, since pdpa names different laws in Singapore, Thailand, Malaysia and Taiwan.'
        sanctions:
          type:
            - array
            - 'null'
          items:
            type: string
            enum:
              - ofac_comprehensive
              - ofac_programme
          description: 'The sanctions programmes that name the country, in this order: ofac_comprehensive (a comprehensive US programme: Cuba, Iran, North Korea) and ofac_programme (any country-related OFAC programme). An empty list when the country is known and no programme names it; null when the country is unknown. A fact about a place, never a verdict about a person; the bundled list names its source and the date it was checked.'
        fatf_list:
          type:
            - string
            - 'null'
          enum:
            - call_for_action
            - increased_monitoring
            - null
          description: 'call_for_action when the FATF lists the country as high-risk (Iran, North Korea, Myanmar), increased_monitoring when it is under increased monitoring (22 jurisdictions after the June 2026 plenary). Null means on neither list, or the country is unknown.'
        region:
          type:
            - string
            - 'null'
          description: 'Null when an operator-declared location disagrees with the database.'
        city:
          type:
            - string
            - 'null'
        latitude:
          type:
            - number
            - 'null'
          description: 'Null when an operator-declared location disagrees with the database, rather than a contradicting point.'
        longitude:
          type:
            - number
            - 'null'
        location_source:
          type:
            - string
            - 'null'
          enum:
            - database
            - operator
            - null
          description: "operator when the network operator publishes the location itself (an RFC 8805 geofeed, Apple's relay list, or a VPN provider's server list) and that declaration was applied; database for the DB-IP Lite answer; null when nothing located the address."
        location_confidence:
          type:
            - string
            - 'null'
          enum:
            - high
            - medium
            - low
            - null
          description: "How far to trust the city: low whenever is_anycast, is_mobile or is_satellite is true, whoever placed it, because the place is then an anycast instance, a carrier gateway or a ground station rather than the user's; otherwise high when the operator declared the location itself, and graded from the weekly accuracy report's city agreement for the country (75 % and above high, 50 % medium, below that low). Null with fewer than 20 compared samples for the country or no report yet."
        timezone:
          type:
            - string
            - 'null'
          description: "IANA zone, e.g. Europe/Berlin: the curated map's answer when the country, or a region known to lie in a single zone, settles it; otherwise the zone of the nearest listed place inside the located country within 250 km. Null when neither settles it, rather than a guess."
        timezone_source:
          type:
            - string
            - 'null'
          enum:
            - region
            - country
            - nearest_city
            - null
          description: 'What settled the zone: region when the map knows the region lies in one zone, country when the country keeps one clock, nearest_city when the nearest GeoNames place inside the country did — the case for split countries and regions such as Texas, Canada, Australia, Brazil, Russia and Indonesia.'
        timezone_matches:
          type:
            - boolean
            - 'null'
          description: "Only when the lookup carried browser_timezone: true when the browser's zone is the located zone or keeps the same clock right now (Paris and Berlin), false when the clocks differ, null when there is nothing to compare or the zone is unknown."
        currency:
          type:
            - string
            - 'null'
          description: 'ISO 4217 currency of the country, e.g. EUR.'
        calling_code:
          type:
            - integer
            - 'null'
          description: "The country's international calling code, e.g. 49."
        asn:
          type:
            - integer
            - 'null'
        org:
          type:
            - string
            - 'null'
        asn_type:
          type:
            - string
            - 'null'
          enum:
            - hosting
            - isp
            - education
            - government
            - null
          description: 'hosting whenever is_datacenter is true or the operator name says so; isp, education and government from the operator name; null when it cannot be told, never guessed.'
        connection_type:
          type:
            - string
            - 'null'
          enum:
            - hosting
            - corporate_proxy
            - satellite
            - mobile
            - residential
            - null
          description: 'What the address is attached to, from positive evidence only and in this precedence: corporate_proxy, hosting (a datacenter range or a verified crawler), satellite, mobile, residential (a dynamic reverse-DNS name, so only with hostname: true). Null when nothing said so; it never means residential by default.'
        is_mobile:
          type:
            - boolean
            - 'null'
          description: 'Whether the autonomous system is a listed mobile carrier, from a curated positive-only list. False means not listed, never a guess; null when the ASN is unknown. Such addresses are shared behind carrier NAT and move with the handset.'
        is_satellite:
          type:
            - boolean
            - 'null'
          description: 'Whether the autonomous system is a listed satellite ISP (Starlink, Viasat, HughesNet), whose addresses land at a ground station rather than near the subscriber. Same semantics as is_mobile.'
        registry:
          type:
            - string
            - 'null'
          enum:
            - arin
            - ripencc
            - apnic
            - lacnic
            - afrinic
            - null
          description: "The regional Internet registry that allocated the space, from the RIRs' daily delegated statistics."
        registry_country:
          type:
            - string
            - 'null'
          description: 'The country the registry recorded for the allocation (ISO 3166-1 alpha-2). A registry country that disagrees with the geolocation is worth a second look.'
        allocated_at:
          type:
            - string
            - 'null'
          format: date
          description: 'The allocation date the registry recorded. A block allocated or transferred weeks ago is a classic fraud tell; it is a fact for the reader, not weighted.'
        abuse_contact:
          type:
            - string
            - 'null'
          description: "The network's abuse mailbox from the registry's RDAP record. Null unless the lookup was asked for with abuse_contact: true, or when the registry lists none."
        network_details:
          type:
            - object
            - 'null'
          description: "The registry's RDAP record for the allocation, only when the lookup was asked for with network_details: true (the same cached registry lookup as abuse_contact). Null otherwise or when the registry could not be read."
          properties:
            name:
              type:
                - string
                - 'null'
              description: 'The network name in the registry, e.g. GOGL.'
            organisation:
              type:
                - string
                - 'null'
              description: "The registrant, else the administrative contact's organisation."
            range:
              type:
                - string
                - 'null'
              description: 'The allocation as start-end, e.g. 8.8.8.0-8.8.8.255.'
            registered_at:
              type:
                - string
                - 'null'
              format: date
            updated_at:
              type:
                - string
                - 'null'
              format: date
        hostname:
          type:
            - string
            - 'null'
          description: 'The reverse DNS (PTR) name of the address, only when the lookup was asked for with hostname: true (a live DNS query with a two-second budget). Null otherwise or when there is none.'
        hostname_verified:
          type:
            - boolean
            - 'null'
          description: 'Whether the PTR name resolves back to the address (forward-confirmed reverse DNS). A verified name under googlebot.com, google.com, search.msn.com, applebot.apple.com, yandex.* or crawl.baidu.* also proves the crawler. Null unless hostname was requested.'
        hostname_kind:
          type:
            - string
            - 'null'
          enum:
            - dynamic
            - static
            - null
          description: 'What the PTR naming convention says the address is: dynamic (a pool, DHCP, dial-up, DSL or cable name) or static (a fixed, dedicated, VPS, cloud or hosting name); null when the name says neither or hostname was not requested.'
        hostname_domain:
          type:
            - string
            - 'null'
          description: 'The registrable domain of the PTR name (comcast.net, amazonaws.com), so the operator behind a reverse name is one field. Null unless hostname was requested.'
        is_datacenter:
          type:
            - boolean
            - 'null'
          description: 'Tri-state — null when no datacenter feed covers the address family.'
        hosting_provider:
          type:
            - string
            - 'null'
          description: "Amazon Web Services, Google Cloud, Microsoft Azure, Oracle Cloud, DigitalOcean, Linode, Vultr, Cloudflare, Fastly or GitHub Actions when the address is inside that provider's own published ranges; null when the flag came only from the aggregate list."
        is_corporate_proxy:
          type:
            - boolean
            - 'null'
          description: "Whether the address is the egress of a corporate secure web gateway, from the cloud enforcement-node ranges Zscaler publishes for its clouds. An employee behind one reaches the Internet from the gateway's address, so the location is the gateway's and the traffic is a workplace: no risk weight, the datacenter weight is skipped for it, and the consistency endpoint withholds its country comparisons. Tri-state."
        corporate_proxy:
          type:
            - string
            - 'null'
          description: 'The gateway operator (Zscaler) when is_corporate_proxy is true.'
        is_crawler:
          type:
            - boolean
            - 'null'
          description: "Whether the address sits in the ranges a crawler operator publishes for verification (Googlebot and Google's special crawlers and user-triggered fetchers, Bingbot, GPTBot, ChatGPT-User, OAI-SearchBot, PerplexityBot, Perplexity-User, DuckDuckBot, AhrefsBot), or a verified hostname proves one. Not a risk: it carries no weight, and the datacenter weight is skipped for it. Tri-state like the other flags."
        crawler:
          type:
            - string
            - 'null'
          description: 'The crawler that owns the range or the verified name: Googlebot, Bingbot, GPTBot, ChatGPT-User, OAI-SearchBot, PerplexityBot, Perplexity-User, DuckDuckBot, AhrefsBot, Applebot, YandexBot, Baiduspider, Google special crawlers or Google user-triggered fetchers. Null unless is_crawler is true.'
        crawler_kind:
          type:
            - string
            - 'null'
          enum:
            - search
            - ai_training
            - ai_assistant
            - seo
            - fetcher
            - null
          description: "What the crawler does: search indexing, AI training, an AI assistant fetching on a user's behalf, SEO crawling, or a fetcher (Google's special crawlers and user-triggered fetchers)."
        is_anycast:
          type:
            - boolean
            - 'null'
          description: 'Whether the address sits in a published anycast range: CDN edges (Cloudflare, Fastly) and the public resolvers (Cloudflare, Google, Quad9, OpenDNS). Geolocation means little for such an address.'
        is_unallocated:
          type:
            - boolean
            - 'null'
          description: "True when no regional Internet registry has allocated or assigned the space, per the five RIRs' daily statistics: the address should not be on the public Internet at all. Null until every registry feed covering the family is installed."
        is_blocklisted:
          type:
            - boolean
            - 'null'
          description: "True when a public threat blocklist names the address's netblock or its network operator: Spamhaus DROP (netblocks hijacked or leased by spam and cybercrime operations, IPv4 and IPv6), Spamhaus ASN-DROP (the operators behind them, matched by the address's ASN) and abuse.ch's Feodo Tracker list of active botnet C2 servers. Null when no blocklist feed covers the address family, or when only ASN-DROP could have answered and the ASN is unknown."
        blocklist:
          type:
            - string
            - 'null'
          description: 'The list that names the address: Spamhaus DROP, Spamhaus ASN-DROP or abuse.ch Feodo Tracker, the first in that order when several do. Null unless is_blocklisted is true.'
        is_attack_source:
          type:
            - boolean
            - 'null'
          description: "True when an attack-source list names the address: single hosts seen attacking honeypots and sensors in the last days, from Emerging Threats' compromised-hosts list, the CINS Army list and blocklist.de, refreshed hourly. Kept apart from is_blocklisted because these lists name hosts rather than netblocks or operators and churn daily; a hit weighs 50. Null when no attack-source feed covers the address family (the three lists are IPv4 today)."
        attack_source:
          type:
            - string
            - 'null'
          description: 'The list that names the address: Emerging Threats compromised hosts, CINS Army or blocklist.de, the first in that order when several do. Null unless is_attack_source is true.'
        is_tor:
          type:
            - boolean
            - 'null'
          description: "From the Tor Project's bulk exit list and its Onionoo relay data, refreshed hourly, so evaluated for IPv4 and IPv6."
        tor_last_seen:
          type:
            - string
            - 'null'
          format: date
          description: 'When the address was last seen exiting Tor: the date of the current list while it is on it, else the date the thirty-day exit history last saw it. An address seen within the last week that is no longer listed carries the recently_tor weight. Null when the history never saw it, or no history is installed.'
        is_vpn:
          type:
            - boolean
            - 'null'
          description: "Known VPN exits: the server lists of Mullvad, NordVPN, Private Internet Access, IVPN, AirVPN and the VPN Gate volunteer relays, the aggregate X4BNet list, and Cloudflare WARP inferred from Cloudflare's own network outside its published CDN ranges. False on iCloud Private Relay egress unless a VPN provider's own list names the address."
        is_relay:
          type:
            - boolean
            - 'null'
          description: "Whether the address is an iCloud Private Relay egress address, from Apple's own published list."
        privacy_service:
          type:
            - string
            - 'null'
          description: 'Mullvad, NordVPN, Private Internet Access, IVPN, AirVPN, VPN Gate or iCloud Private Relay when a provider''s own list names the address, or "Cloudflare WARP (inferred)" when Cloudflare''s own network outside its published ranges did — the suffix says it is an inference, which a published list is not; null for Tor and for VPN ranges known only from the aggregate list.'
        is_anonymous:
          type:
            - boolean
            - 'null'
          description: 'True when any of is_tor, is_vpn or is_relay is true; false when at least one of them was checked and none matched; null when none was evaluated.'
        account_list:
          type:
            - object
            - 'null'
          description: "The account's own allow or deny list naming the address, or null. Deny lists are checked before allow lists. An allow-list hit zeroes the score; a deny-list hit adds the denylisted weight of 100."
          properties:
            id:
              type: integer
            name:
              type: string
            kind:
              type: string
              enum:
                - allow
                - deny
            entry:
              type: string
              description: 'The address or prefix on the list that matched.'
        risk_score:
          type:
            - integer
            - 'null'
          description: 'denylisted 100 + unallocated 100 + blocklist 80 + Tor 70 + reported_abuse 60 + attack_source 50 + datacenter 40 + reported_abuse_widely 40 + recently_tor 40 + VPN 30 + relay 10, capped at 100, over the signals actually checked; the datacenter weight is skipped when is_relay, is_crawler or is_corporate_proxy is true. An allow-list hit answers 0 whatever else fired. Null only when no feed evaluated the address and nobody reported or listed it.'
        risk_level:
          type:
            - string
            - 'null'
          enum:
            - low
            - medium
            - high
            - null
        risk_signals:
          type: array
          description: 'The signals behind the score, each with the weight actually applied and the dataset that produced it; the weights sum to risk_score. An allow-list hit is the one entry allowlisted with weight 0 and nothing beside it.'
          items:
            type: object
            properties:
              signal:
                type: string
                enum:
                  - denylisted
                  - unallocated
                  - blocklist
                  - tor
                  - reported_abuse
                  - attack_source
                  - datacenter
                  - reported_abuse_widely
                  - recently_tor
                  - vpn
                  - relay
                  - allowlisted
              weight:
                type: integer
              dataset:
                type: string
        seen_by_you:
          type:
            - object
            - 'null'
          description: 'How often this account looked the address up inside the window before this call, and when it first did — a keyed hash and a count, never the address. Null under privacy mode, for reserved addresses, and on the guest demo.'
          properties:
            count:
              type: integer
              description: 'Earlier lookups inside the window; 0 on the first sighting.'
            first_seen_at:
              type:
                - string
                - 'null'
              format: date-time
            window_days:
              type: integer
        sources:
          type: array
          items:
            $ref: '#/components/schemas/Source'
    IpBatchResponse:
      type: object
      properties:
        success:
          type: boolean
        data:
          type: object
          properties:
            results:
              type: array
              items:
                type: object
                properties:
                  index:
                    type: integer
                    description: "The item's zero-based position in the submitted list. A batch truncated by `stopped_reason` answers only a prefix of the list, so this is what lines an answer up with its submission."
                  input:
                    type: string
                    description: 'The address exactly as it was submitted; `data.ip` is its canonical form.'
                  data:
                    $ref: '#/components/schemas/IpResult'
                  meta:
                    type: object
                    properties:
                      credits_used:
                        type: integer
                      cache_hit:
                        type: boolean
                      ip_lookups_prepaid:
                        type: integer
                        description: 'IP lookups the account holds prepaid after this item; see IpMeta.'
        meta:
          type: object
          properties:
            requested:
              type: integer
            processed:
              type: integer
            credits_used:
              type: integer
            credits_remaining:
              type: integer
            ip_lookups_prepaid:
              type: integer
              description: 'IP lookups the account holds prepaid after the whole list; see IpMeta.'
            stopped_reason:
              type:
                - string
                - 'null'
              enum:
                - insufficient_credits
                - key_spend_cap
                - null
            request_id:
              type:
                - string
                - 'null'
    IpBulkJob:
      type: object
      properties:
        id:
          type: integer
        status:
          type: string
          enum:
            - queued
            - processing
            - completed
            - failed
            - cancelled
        total:
          type: integer
          description: 'Distinct addresses in the run.'
        duplicate_count:
          type: integer
          description: 'Input rows that repeated an earlier address.'
        processed:
          type: integer
        low:
          type: integer
          description: 'Processed addresses with risk_level low.'
        medium:
          type: integer
        high:
          type: integer
        summary:
          allOf:
            -
              $ref: '#/components/schemas/IpBulkSummary'
            -
              type:
                - object
                - 'null'
              description: "What the finished run's rows add up to; null until the run has settled."
        credits_used:
          type: integer
        stopped_reason:
          type:
            - string
            - 'null'
          enum:
            - insufficient_credits
            - null
        cancel_requested:
          type: boolean
        webhook_status:
          type:
            - string
            - 'null'
          enum:
            - pending
            - delivered
            - failed
            - null
          description: "The last delivery's state: `pending` while one has been asked for and has not answered yet, then `delivered` or `failed`. Null before the first one."
        webhook_detail:
          type:
            - string
            - 'null'
          description: 'Why the attempt ended as it did: the HTTP status the endpoint answered with, or the transport failure when it answered at all. The response body is never stored.'
        created_at:
          type: string
          format: date-time
        finished_at:
          type:
            - string
            - 'null'
          format: date-time
    IpBulkSummary:
      type: object
      description: "What a finished bulk IP run's rows add up to, built from the same cells the result file holds so it can never disagree with the download."
      properties:
        rows:
          type: integer
          description: 'Rows written, reserved ones included.'
        reserved:
          type: integer
          description: 'Rows whose address was private or special-use space.'
        countries:
          type: array
          description: 'The ten most frequent countries, most rows first.'
          items:
            type: object
            properties:
              country:
                type: string
              rows:
                type: integer
        networks:
          type: array
          description: 'The ten most frequent autonomous systems, most rows first.'
          items:
            type: object
            properties:
              asn:
                type: integer
              org:
                type:
                  - string
                  - 'null'
              rows:
                type: integer
        signals:
          type: object
          description: 'How many rows answered true for each flag.'
          properties:
            datacenter:
              type: integer
            corporate_proxy:
              type: integer
            crawler:
              type: integer
            tor:
              type: integer
            vpn:
              type: integer
            relay:
              type: integer
            anonymous:
              type: integer
            blocklisted:
              type: integer
            attack_source:
              type: integer
            mobile:
              type: integer
            satellite:
              type: integer
        listed:
          type: object
          description: "Rows one of the account's own lists named."
          properties:
            allow:
              type: integer
            deny:
              type: integer
        risk:
          type: object
          properties:
            low:
              type: integer
            medium:
              type: integer
            high:
              type: integer
    IpList:
      type: object
      description: "One of the account's own allow or deny lists. A list belongs to the account: every member's lookups apply it and any member may change or remove it."
      properties:
        id:
          type: integer
        name:
          type: string
          description: 'The label, answered back in account_list.name on every lookup the list matches.'
        kind:
          type: string
          enum:
            - allow
            - deny
          description: 'allow zeroes the score of a matching address; deny adds the denylisted weight of 100. Deny lists are checked first, so a contradiction fails closed.'
        entry_count:
          type: integer
          description: 'Distinct addresses and prefixes on the list.'
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
          description: 'The last change, which is the account-lists source version on a lookup the list matched.'
        entries:
          type: array
          items:
            type: string
          description: 'The addresses and prefixes in canonical form. On the single-list, create and update answers; the index leaves them out.'
    AsnResult:
      type: object
      description: "One autonomous system as the data on this server knows it, the reverse of an address lookup's asn field."
      properties:
        asn:
          type: integer
        org:
          type:
            - string
            - 'null'
          description: 'The organisation operating the system, from the ASN database.'
        asn_type:
          type:
            - string
            - 'null'
          enum:
            - hosting
            - isp
            - education
            - government
            - null
          description: 'From the operator name alone; null when it cannot be told.'
        registry:
          type:
            - string
            - 'null'
          enum:
            - arin
            - ripencc
            - apnic
            - lacnic
            - afrinic
            - null
          description: 'The registry that allocated the number, from the RIR delegated statistics.'
        registry_country:
          type:
            - string
            - 'null'
        allocated_at:
          type:
            - string
            - 'null'
          format: date
          description: 'When the registry recorded the number as allocated.'
        is_blocklisted:
          type:
            - boolean
            - 'null'
          description: 'Whether Spamhaus ASN-DROP names the operator. Tri-state like the address flags.'
        blocklist:
          type:
            - string
            - 'null'
        is_satellite:
          type:
            - boolean
            - 'null'
          description: 'Whether the system is on the curated satellite-operator list.'
        is_mobile:
          type:
            - boolean
            - 'null'
          description: 'Whether the system is on the curated mobile-carrier list.'
        prefix_count:
          type: object
          description: 'How many prefixes the database assigns to the system, per family; always whole even when the lists below are capped.'
          properties:
            ipv4:
              type: integer
            ipv6:
              type: integer
        prefixes:
          type: object
          description: 'The prefixes themselves, at most 500 per family in index order.'
          properties:
            ipv4:
              type: array
              items:
                type: string
            ipv6:
              type: array
              items:
                type: string
        truncated:
          type: boolean
          description: 'True when either family had more prefixes than the 500 answered.'
        sources:
          type: array
          items:
            $ref: '#/components/schemas/Source'
    IpRangeSignal:
      type: object
      properties:
        overlaps:
          type:
            - boolean
            - 'null'
          description: 'Whether any installed feed of the signal intersects the prefix; null when no installed feed of that signal covers the address family, exactly like the address flags.'
        ranges:
          type: integer
          description: "How many feed records intersect the prefix, summed over the signal's feeds, capped at 10,000 per feed."
        providers:
          type: array
          items:
            type: string
          description: 'The distinct providers whose own feeds intersect it, in config order; empty for anonymous lists.'
    IpRangeResult:
      type: object
      description: 'One prefix: which feeds overlap it, whose network and country its two ends resolve to, and the registry facts of its first address.'
      properties:
        prefix:
          type: string
          description: 'The canonical prefix: 8.8.8.9/24 answers as 8.8.8.0/24, and a bare address as a /32 or /128.'
        version:
          type: integer
          enum:
            - 4
            - 6
        reason:
          type:
            - string
            - 'null'
          enum:
            - reserved_range
            - null
          description: 'reserved_range when the prefix lies in private or special-use space; every other field is then null and the call is free.'
        first:
          type:
            - string
            - 'null'
        last:
          type:
            - string
            - 'null'
        addresses:
          type:
            - integer
            - 'null'
          description: 'How many addresses the prefix holds; null when two to the host bits does not fit a 64-bit integer.'
        network:
          type: object
          description: 'The autonomous system of the first address.'
          properties:
            asn:
              type:
                - integer
                - 'null'
            org:
              type:
                - string
                - 'null'
        network_agrees:
          type:
            - boolean
            - 'null'
          description: 'Whether the last address resolves to the same system; null when either end is unknown.'
        country:
          type:
            - string
            - 'null'
          description: 'The country of the first address.'
        country_agrees:
          type:
            - boolean
            - 'null'
          description: 'Whether the last address is located in the same country; null when either end is unknown.'
        registry:
          type:
            - string
            - 'null'
          enum:
            - arin
            - ripencc
            - apnic
            - lacnic
            - afrinic
            - null
        registry_country:
          type:
            - string
            - 'null'
        allocated_at:
          type:
            - string
            - 'null'
          format: date
        signals:
          type: object
          description: 'One answer per signal group.'
          properties:
            datacenter:
              $ref: '#/components/schemas/IpRangeSignal'
            vpn:
              $ref: '#/components/schemas/IpRangeSignal'
            relay:
              $ref: '#/components/schemas/IpRangeSignal'
            tor:
              $ref: '#/components/schemas/IpRangeSignal'
            anycast:
              $ref: '#/components/schemas/IpRangeSignal'
            allocated:
              $ref: '#/components/schemas/IpRangeSignal'
            blocklist:
              $ref: '#/components/schemas/IpRangeSignal'
            attack_source:
              $ref: '#/components/schemas/IpRangeSignal'
            crawler:
              $ref: '#/components/schemas/IpRangeSignal'
            corporate_proxy:
              $ref: '#/components/schemas/IpRangeSignal'
        truncated:
          type: boolean
          description: "True when any feed's records were capped, so a count is a floor."
        sources:
          type: array
          items:
            $ref: '#/components/schemas/Source'
    LedgerEntry:
      type: object
      properties:
        id:
          type: integer
        delta:
          type: integer
          description: 'Signed credit movement.'
        balance_after:
          type: integer
        lookups:
          type: integer
          description: 'How many answered lookups the row paid for: 5 on an ip_lookup block written since IP lookups were billed in blocks, 1 on any other lookup debit (and on ip_lookup rows written before), 0 on deposits, grants, purchases, adjustments and expiries. Add it up instead of counting rows to reconcile usage.'
        reason:
          type: string
          description: "Why the balance moved, e.g. email_lookup, ip_lookup, monthly_grant, signup_bonus, purchase, subscription, auto_recharge, plan_credits_expired. An ip_lookup debit of 1 pays for a block of five answered IP lookups. A subscription row is a plan's monthly deposit; a plan_credits_expired row takes back plan credits: the part of a deposit above three months' allowance (reference cap:…), what is left 60 days after a plan ended (reference lapsed:…), or, when a yearly plan moves up, the share of the current month's plan credits that the credit for the unused year pays back (reference upgrade:…). Pack, grant, bonus and top-up credits never expire."
        reference:
          type:
            - string
            - 'null'
        via:
          type:
            - string
            - 'null'
          description: 'The key, widget, bulk run or monitor label that spent it; null for grants and purchases.'
        api_token_id:
          type:
            - integer
            - 'null'
          description: 'The secret key that spent it, which two keys of the same name do not share and a rename does not move; null for the dashboard, bulk runs, monitors, grants and purchases.'
        created_at:
          type: string
          format: date-time
    DataStatus:
      type: object
      properties:
        ok:
          type: boolean
          description: 'False when any component is degraded. A component nobody switched on is not degraded.'
        checked_at:
          type: string
          format: date-time
        components:
          type: array
          description: 'One entry per moving part behind an answer.'
          items:
            type: object
            properties:
              component:
                type: string
                enum:
                  - database
                  - ip-geolocation
                  - mailbox-handshake
                  - live-carrier-check
                  - ssr
              name:
                type: string
                description: "The component's public name."
              state:
                type: string
                enum:
                  - operational
                  - degraded
                  - not_configured
              detail:
                type: string
                description: 'Why it is in that state, in a sentence.'
              retry_in_seconds:
                type:
                  - integer
                  - 'null'
                description: 'For a degraded component that is holding off, the seconds until the next attempt.'
        availability:
          type: object
          description: 'Ninety days of measured availability, rolled up daily from the request timings. Read what it measures before you publish it: for each UTC day it is the share of the requests we RECORDED that were not answered with a fault on our side (status 500 and above). A refusal that is an answer about the request — an empty balance, a rate limit, a validation failure — is not counted against it, the same rule the latency percentiles follow. It is not an uptime figure and is not an SLA. Read it as a floor on the bad days, never as proof of a good one. It only counts requests that reached this server and were recorded, so an outage that made the server unreachable records nothing: a whole day of it reads `state` `no_requests` with `availability` null, and a blank day is never counted as a good one — but an hour of it leaves the other twenty-three, and the day can read as a clean 100. A partial outage is the common case and this figure is close to blind to it. A fault that also stops the recording write, such as a database incident, takes those failures out of the figure along with everything else.'
          properties:
            days:
              type: integer
              description: 'How many days the window covers, including today.'
            since:
              type: string
              format: date
              description: 'The first UTC day in the window.'
            until:
              type: string
              format: date
              description: 'The last UTC day in the window, which is today and is not over.'
            measured_days:
              type: integer
              description: 'How many days in the window recorded at least one request.'
            days_without_requests:
              type: integer
              description: 'How many recorded none. The percentage says nothing about these.'
            requests:
              type: integer
              description: 'Every request recorded across the window.'
            answered:
              type: integer
              description: 'How many were answered with a status below 400. The difference between this and `requests`, minus `failed`, is what was refused.'
            failed:
              type: integer
              description: 'How many of them failed on this side.'
            availability:
              type:
                - number
                - 'null'
              description: 'Percentage of recorded requests answered without a fault here, weighted by request rather than averaged over days; null when the window recorded nothing.'
            history:
              type: array
              description: 'One entry per day, oldest first.'
              items:
                type: object
                properties:
                  date:
                    type: string
                    format: date
                  requests:
                    type: integer
                  answered:
                    type: integer
                  failed:
                    type: integer
                  availability:
                    type:
                      - number
                      - 'null'
                    description: 'Null when the day recorded no requests — never 100.'
                  state:
                    type: string
                    enum:
                      - operational
                      - degraded
                      - no_requests
                    description: '`operational` when requests were recorded and none failed here, `degraded` when at least one did, `no_requests` when nothing was recorded at all.'
                  complete:
                    type: boolean
                    description: 'False for the last entry, which is today and still running.'
        datasets:
          type: array
          items:
            type: object
            properties:
              dataset:
                type: string
              version:
                type:
                  - string
                  - 'null'
                description: "The publisher's own release date where the file states one, otherwise the build or sync date. A dataset made of several feeds, countries or files carries the oldest of them."
              installed:
                type: boolean
                description: 'Whether the dataset has data to answer from. A feed group whose only installed feed ships with the app is installed with no sync date, and so is a register published as several files until every file has synced.'
              feeds:
                type:
                  - integer
                  - 'null'
                description: 'How many parts the row aggregates: the feeds of a feed group, or, when there is more than one, the countries of a postcode directory or the files of a register published in several (one per state, for instance); null otherwise.'
              freshness:
                type: string
                enum:
                  - current
                  - stale
                  - missing
                  - not_dated
                  - unchecked
                description: "Against the dataset's own freshness window, which follows its sync schedule (plus the publisher's release cycle when the version is the publisher's date), so a single missed scheduled run turns a row `stale`; a list curated by hand is judged against a review window instead, which for `compliance-lists` counts from the FATF plenary its lists reflect rather than from its version, the day it was checked. `current` inside the window, `stale` past it, `missing` never synced, `not_dated` for a version that carries no date, `unchecked` for a dataset whose window is switched off."
              age_days:
                type:
                  - integer
                  - 'null'
                description: 'How old the installed version is, when the version is a date.'
        latency:
          type: array
          items:
            type: object
            properties:
              group:
                type: string
                enum:
                  - email
                  - ip
                  - phone
                  - address
                  - entity
                  - consistency
                  - account
              window:
                type: string
                enum:
                  - 1h
                  - 24h
              p50_ms:
                type:
                  - integer
                  - 'null'
                description: 'Over the requests that succeeded; null when every request in the window was refused.'
              p95_ms:
                type:
                  - integer
                  - 'null'
                description: 'Over the requests that succeeded; null when every request in the window was refused.'
              success_rate:
                type: number
                description: 'Percentage of requests in the window answered with a status below 400.'
              error_rate:
                type: number
                description: 'Percentage that failed on this side (status 500 and above). A 402, 429 or 422 is an answer about the request and is not counted here.'
              answered:
                type: integer
                description: 'How many of the sample were answered. The percentiles are taken over these, and are withheld unless there are at least 20.'
              sample:
                type: integer
                description: 'Every request in the window, answered or refused.'
