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 email lookups through `POST /api/v1/email/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 `settling` when a callback was requested). 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, a located IP) | 1 |
    | Invalid input or an undeliverable verdict | 0 |
    | Repeat of a recent lookup, within 7 days | 0 |
    | Repeat asked for fresh with `refresh` or an exceeded `max_age`, when answered | 1 |
    | Unverified answer caused by the handshake partner being unavailable (`smtp_reason` `provider_unavailable`) | 0 |
    | Rejected or failed request | 0 |
    | Monthly free grant, every account | +10 |

    Provider-outage answers are never cached, so the next lookup of the address probes again and bills as fresh when answered.

    Credit packs are one-time purchases from the dashboard's Billing page (from $9 for 1,000 credits); monthly plans deposit the same credits every month for less (from $7 a month) and can be cancelled at any time. Credits from either never expire. `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 and risk flags.'
  -
    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, and 27 named fields. The checks run in order: address extraction (display names, `mailto:` prefixes, spreadsheet quotes and invisible characters are stripped), RFC 5322 syntax, live MX resolution including the null-MX and implicit-MX rules, the disposable, role, free-provider and typo-squat lists, provider-specific username rules, SPF and DMARC records, the domain's registration age from RDAP, and finally a mailbox-level SMTP handshake that ends before any message is transmitted.

        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: an explicit re-verify is the only way an address earns its way off 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. `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`. Re-checks are repeats of a charged lookup, so they cost nothing; `settled` is false when the last re-check still could not say.
      x-spaw-auth: bearer
      x-spaw-billing: '1 credit for a fresh deliverable or risky verdict; undeliverable verdicts, invalid input, 7-day repeats and test addresses are free; an unverified answer caused by a provider outage is free.'
      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`.'
            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
                  deliverable: deliverable
                  reason: null
                  risk_score: 0
                  risk_level: low
                  syntax_valid: true
                  domain: acme.com
                  mx_found: true
                  mx_implicit: false
                  mx_provider: google
                  has_spf: true
                  dmarc_policy: reject
                  domain_registered_at: '1998-03-12'
                  domain_age_days: 10402
                  disposable: false
                  role: false
                  free_provider: false
                  smtp_checked: true
                  smtp_checked_at: '2026-09-03T10:12:44+00:00'
                  mailbox_exists: true
                  catch_all: false
                  smtp_reason: null
                  mailbox_confidence: null
                  did_you_mean: 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
                  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`, and `suppressed`.

        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, everything else free, including an unverified answer caused by a provider outage — 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
                    -
                      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
                    -
                      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
                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.'
                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
        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
                  mx_found: true
                  mx_implicit: false
                  catch_all: null
                  mx_provider: google
                  has_spf: true
                  dmarc_policy: reject
                  domain_registered_at: '1998-03-12'
                  domain_age_days: 10402
                  disposable: false
                  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'
                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, normalized_email, is_alias, is_gibberish, disposable, role, free_provider, mx_found, mx_implicit, mx_provider, has_spf, dmarc_policy, domain_registered_at, domain_age_days, smtp_checked, smtp_checked_at, mailbox_exists, catch_all, smtp_reason, mailbox_confidence, did_you_mean, 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,normalized_email,is_alias,is_gibberish,disposable,role,free_provider,mx_found,mx_implicit,mx_provider,has_spf,dmarc_policy,domain_registered_at,domain_age_days,smtp_checked,smtp_checked_at,mailbox_exists,catch_all,smtp_reason,mailbox_confidence,did_you_mean,credits_used,cache_hit,suppressed
                mia@acme.com,mia@acme.com,deliverable,,0,low,mia@acme.com,0,0,0,0,0,1,0,google,1,reject,1998-03-12,10402,1,2026-09-03T10:12:44+00:00,1,0,,,,1,0,0
            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 any verdict other than undeliverable removes the entry. Entries untouched for 90 days are 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`. Entries that already exist are never overwritten, because a verified auto-suppression outranks an import, and are counted as `already_suppressed`. 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.
      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; undeliverable, suppressed and 7-day repeats are free.'
      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`, a delivery removes an entry Spaw created automatically. 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. |

        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.
      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/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 monthly plan while it is paid up: `starter`, `growth`, `scale` or `volume`), 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: 9
                    monthly_grant: 10
        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. 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.'
        -
          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: 8
                    reason: ip_lookup
                    reference: null
                    via: 'api · Production'
                    api_token_id: 41
                    created_at: '2026-09-04T10:12:44+00:00'
                  -
                    id: 9801
                    delta: 10
                    balance_after: 10
                    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, Ofcom's drama ranges), `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 older than a year, 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, reserved_block 40, virtual 40, voip 30, pager 30, digit_pattern 30, shared_cost 20; 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.

        `hlr: true` asks for the live carrier-network check (reachable, ported, current carrier, roaming). While the check 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.

        Seven 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) and 0106 (invalid). 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. Asking for the live check (hlr) adds 3 credits only when it 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:
                - phone
              properties:
                phone:
                  type: string
                  maxLength: 32
                  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. Reserves the premium credits up front and charges them only when the check answers.'
            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/Meta'
              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
                  sources:
                    -
                      dataset: test-numbers
                      version: bundled
                meta:
                  credits_used: 0
                  credits_remaining: 10
                  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/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` and `cache_hit`.

        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; the live carrier-network check is available on single lookups only.

        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: 32
                    required:
                      - phone
                    properties:
                      phone:
                        type: string
                        maxLength: 32
                        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, and the balance is gated per item before any network query.'
            example:
              phones:
                - '(415) 555-0142'
                -
                  phone: '020 7946 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
                      meta:
                        credits_used: 1
                        cache_hit: false
                    -
                      index: 1
                      input: '020 7946 0018'
                      data:
                        valid: false
                        reason: unassigned_block
                        e164: '+442079460018'
                        country: GB
                        block_status: unassigned
                        is_fictional: true
                        risk_score: null
                        risk_level: null
                      meta:
                        credits_used: 0
                        cache_hit: false
                    -
                      index: 2
                      input: 'not a number'
                      data:
                        valid: false
                        reason: not_a_number
                        e164: null
                        country: null
                        risk_score: null
                        risk_level: null
                      meta:
                        credits_used: 0
                        cache_hit: 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 7946 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 7946 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 7946 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 7946 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 7946 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 7946 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 7946 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, how much of the range is mobile or VoIP, and the place, time zones and line types the numbering plan names for 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 in national notation, with no calling code: `/v1/phone/block/us/415`, `/v1/phone/block/gb/020`. A longer prefix narrows the report to the blocks underneath it.

        A country whose numbering plan Spaw does not carry — including a region whose numbers live in another's table, as the Crown dependencies do under GB — and a prefix no block starts with 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 in national notation, digits only and no calling code.'
      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'
                  timezones:
                    - America/Los_Angeles
                  line_types:
                    - fixed_line_or_mobile
                  example: '+1 415-200-0000'
                  blocks:
                    total: 792
                    allocated: 604
                    unassigned: 171
                    reserved: 17
                  block_length: 6
                  kinds:
                    mobile: 41.2
                    voip: 12.7
                    unsettled: 46.1
                  holders:
                    -
                      holder: 'PACIFIC BELL'
                      kind: null
                      blocks: 96
                      share: 15.9
                    -
                      holder: 'T-MOBILE USA, INC.'
                      kind: mobile
                      blocks: 71
                      share: 11.8
                  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: 32
                    required:
                      - phone
                    properties:
                      phone:
                        type: string
                        maxLength: 32
                        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, 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.

        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/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 except the live-check fields as a column, and the credits it cost. Time zones and sources are joined by `;` (sources as `dataset@version`), risk signals as `signal:weight` pairs. Fields containing spaces or commas are quoted. Add `?variant=valid|invalid|low|medium|high` to download only the valid or invalid rows, 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 valid or invalid rows, 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: |
                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,risk_score,risk_level,risk_signals,sources,credits_used
                +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,libphonenumber@9.0.37;nanpa-co-codes@2026-09-04;fictional-ranges@2026-09-04;disposable-phone-numbers@2026-09-04,1
            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
                      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. 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: 32
                  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.'
          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
        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), or `abused` (the code was used by someone who was not the recipient, or the number was involved in fraud). 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 undelivered scores `reported_undelivered` (+50), a number with any abuse report within 90 days scores `reported_abuse` (+60), and a delivery reported after a failure cancels that failure. No other account ever sees your reports.

        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
                -
                  phone: '(415) 555-0142'
                  country: US
                  outcome: delivered
                  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 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"
                },
                {
                  "phone": "(415) 555-0142",
                  "country": "US",
                  "outcome": "delivered",
                  "occurred_at": "2026-09-05T09:00:00Z"
                }
              ]
            }'
        -
          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"
                  },
                  {
                    "phone": "(415) 555-0142",
                    "country": "US",
                    "outcome": "delivered",
                    "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/phone/feedback',
                headers={'Authorization': 'Bearer sk_live_…'},
                json={
                    'items': [
                        {
                            'phone': '+44 7911 012345',
                            'outcome': 'undelivered'
                        },
                        {
                            'phone': '(415) 555-0142',
                            'country': 'US',
                            'outcome': 'delivered',
                            'occurred_at': '2026-09-05T09:00:00Z'
                        }
                    ]
                },
            )
            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'
                        ],
                        [
                            'phone' => '(415) 555-0142',
                            'country' => 'US',
                            'outcome' => 'delivered',
                            '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_phone_feedback([
                {
                    'phone': '+44 7911 012345',
                    'outcome': 'undelivered'
                },
                {
                    'phone': '(415) 555-0142',
                    'country': 'US',
                    'outcome': 'delivered',
                    '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.reportPhoneFeedback([
                {
                    phone: '+44 7911 012345',
                    outcome: 'undelivered'
                },
                {
                    phone: '(415) 555-0142',
                    country: 'US',
                    outcome: 'delivered',
                    occurred_at: '2026-09-05T09:00:00Z'
                }
            ]);
        -
          lang: PHP
          label: 'PHP SDK'
          source: |-
            use Spaw\Client;

            $spaw = new Client('sk_live_…');
            $result = $spaw->reportPhoneFeedback([
                [
                    'phone' => '+44 7911 012345',
                    'outcome' => 'undelivered'
                ],
                [
                    'phone' => '(415) 555-0142',
                    'country' => 'US',
                    'outcome' => 'delivered',
                    'occurred_at' => '2026-09-05T09:00:00Z'
                ]
            ]);
  /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, how many distinct numbers they cover, and the ten blocks (region plus the first six digits of the national number) with the most failed deliveries, with their delivered and abused counts alongside. A bad list or a wholesaler's range shows up here first.
      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: 121
                    abused: 11
                  top_undelivered_blocks:
                    -
                      country: US
                      block: '201201'
                      undelivered: 38
                      delivered: 2
                      abused: 4
                    -
                      country: GB
                      block: '770090'
                      undelivered: 12
                      delivered: 0
                      abused: 0
        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, or after two undelivered reports with no later delivery; 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, and a valid answer there removes the entry. Entries untouched for 90 days are re-checked instead of trusted.

        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
          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`. Entries that already exist are never overwritten and are counted as `already_suppressed`. 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: 32
                      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 32 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: 32
                  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), 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
                        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. 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.

        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": [ … ] }`, where `changes` names the numbers 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 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.'
      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: 32
                  description: '1 to 500 numbers in any notation; each item at most 32 characters.'
                cadence:
                  type: string
                  enum:
                    - weekly
                    - monthly
                  description: 'How often the list is re-checked.'
                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 7946 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 7946 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 7946 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 7946 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 7946 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 7946 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 7946 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 7946 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` or `unreachable`), keyed by the number 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: 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 or unreachable), keyed by the number; null until the first run finishes."
              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
                      changed: 0
                      decayed: 0
                      credits_used: 1
                      stopped_reason: null
                    created_at: '2026-08-27T08:00:00+00:00'
                    results:
                      '+44 7911 012345': valid
                      '020 7946 0018': unassigned
        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. 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: 32
                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
                      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/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 and iCloud Private Relay addresses from open and first-party feeds: the Tor Project's exit list, X4BNet's datacenter and VPN lists, the published ranges of AWS, Google Cloud, Microsoft Azure, Oracle Cloud, DigitalOcean, Linode, Vultr, Cloudflare and Fastly, the server lists of Mullvad, NordVPN and Private Internet Access, and Apple's relay egress list. `hosting_provider` and `privacy_service` name the provider when its own feed contains the address; `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, 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), `currency`, `calling_code`, and `timezone` when the country, or a region known to lie in a single zone, settles it (split regions such as Texas answer null rather than a guess). `network` is the prefix the location answer covers, and `location_confidence` grades the city: 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.

        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. Tor exits are read from both the Tor Project's bulk list and its Onionoo relay data, so `is_tor` is evaluated for IPv6 too. 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; 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.

        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 — unallocated 100, blocklist 80, Tor 70, reported_abuse 60, datacenter 40, reported_abuse_widely 40, VPN 30, relay 10, capped at 100; the datacenter weight is skipped when `is_relay` or `is_crawler` is true because relay egress and verified crawlers 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 trace is a keyed seven-day marker that makes a repeat free. Send `privacy: true` to skip even that marker; every such call is then a fresh, billable lookup.

        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. IP endpoints have their own rate limit of 50 requests per second per key.
      x-spaw-auth: bearer
      x-spaw-billing: '1 credit for a fresh lookup that resolves at least one signal (a location, a network, or a positive threat flag: Tor, datacenter, VPN, relay or unallocated); reserved ranges, unknown addresses and 7-day repeats are free. 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."
            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/Meta'
              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
                  region: California
                  city: 'Mountain View'
                  latitude: 37.4056
                  longitude: -122.0775
                  location_source: database
                  location_confidence: medium
                  timezone: America/Los_Angeles
                  currency: USD
                  calling_code: 1
                  asn: 15169
                  org: 'Google LLC'
                  asn_type: hosting
                  registry: arin
                  registry_country: US
                  allocated_at: '2023-12-28'
                  abuse_contact: null
                  network_details: null
                  hostname: null
                  hostname_verified: null
                  is_datacenter: true
                  hosting_provider: null
                  is_crawler: false
                  crawler: null
                  crawler_kind: null
                  is_anycast: true
                  is_unallocated: false
                  is_blocklisted: false
                  blocklist: null
                  is_tor: false
                  is_vpn: false
                  is_relay: false
                  privacy_service: null
                  is_anonymous: false
                  risk_score: 40
                  risk_level: medium
                  risk_signals:
                    -
                      signal: datacenter
                      weight: 40
                      dataset: datacenter-ranges
                  sources:
                    -
                      dataset: dbip-city-lite
                      version: 2026-09
                    -
                      dataset: dbip-asn-lite
                      version: 2026-09
                    -
                      dataset: tor-exit-list
                      version: '2026-09-04'
                    -
                      dataset: datacenter-ranges
                      version: '2026-09-04'
                    -
                      dataset: vpn-ranges
                      version: '2026-09-04'
                    -
                      dataset: relay-ranges
                      version: '2026-09-04'
                    -
                      dataset: anycast-ranges
                      version: '2026-09-04'
                    -
                      dataset: rir-allocations
                      version: '2026-09-04'
                    -
                      dataset: threat-blocklists
                      version: '2026-09-04'
                    -
                      dataset: crawler-ranges
                      version: '2026-09-05'
                    -
                      dataset: operator-geofeeds
                      version: '2026-09-05'
                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 -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.
      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.'
      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/Meta'
              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
        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.
      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.'
      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/Meta'
              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: 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/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` and `cache_hit`.

        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 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 for a fresh answered lookup, reserved ranges, 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
                    -
                      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: 1
                        cache_hit: false
                    -
                      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
                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/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.

        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.'
            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. 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
          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. 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
                    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 every field of the single-address response as a column plus the credits it cost. 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,region,city,latitude,longitude,location_source,location_confidence,timezone,currency,calling_code,asn,org,asn_type,registry,registry_country,allocated_at,is_datacenter,hosting_provider,is_crawler,crawler,crawler_kind,is_blocklisted,blocklist,is_tor,is_vpn,is_relay,privacy_service,is_anonymous,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,medium,America/Los_Angeles,USD,1,15169,Google LLC,hosting,arin,US,2023-12-28,true,,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 for a fresh answered lookup; reserved addresses and 7-day repeats are free, so a daily list costs at most one credit per address 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; duplicates and alternative spellings of one address count once.'
                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`, `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
                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; reserved 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/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`, `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`.

        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 the whole directory is installed, whether it exists (`postcode_status`) and whether the town written on the address is the town that postcode belongs to (`locality_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 claims to list every address in its country (`register_coverage: "complete"`) and does not carry this one, and `null` otherwise — including a register that covers only part of its country (`register_coverage: "partial"`, which is what the United States National Address Database is, because states share their data voluntarily). `match_level` says how close the register got (`premise`, `street`, `postcode`, `none`).

        The `risk_score` sums the weights of the signals in `risk_signals` (undeliverable 80, private_mailbox 60, mail_drop 50, vacant 40, general_delivery 40, po_box 30, terminated_postcode 30, locality_mismatch 30, not_in_register 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, and `not_in_register` fires only when `exists` is false, so a register covering part of a country never contributes to the score.

        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.

        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.'
                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. Reserves the premium credits up front and charges them only when the partner answered.'
            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
                  organization: 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_valid: true
                  postal_code_type: zip
                  postcode_status: live
                  locality_matches_postcode: true
                  address_type: street
                  address_type_source: address_rules
                  is_po_box: false
                  is_private_mailbox: null
                  exists: null
                  match_level: street
                  register: us-national-address-database
                  register_coverage: partial
                  latitude: 37.4224
                  longitude: -122.0841
                  location_source: postcode_centroid
                  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
                  risk_score: 0
                  risk_level: low
                  risk_signals: []
                  sources:
                    -
                      dataset: address-formats
                      version: '2026-09-05'
                    -
                      dataset: address-rules
                      version: '2026-09-05'
                    -
                      dataset: us-national-address-database
                      version: '2026-08-14'
                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
                      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
                      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` and risk signals as `signal:weight`; `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,organization,street,house_number,unit,po_box,dependent_locality,locality,administrative_area,postal_code,postal_code_valid,postal_code_type,postcode_status,locality_matches_postcode,address_type,address_type_source,is_po_box,is_private_mailbox,exists,match_level,register,register_coverage,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,risk_score,risk_level,risk_signals,sources,credits_used,suppressed
                "1600 Amphitheatre Parkway, Mountain View, CA 94043",true,,US,United States,NA,false,false,ccpa,"1600 AMPHITHEATRE PKWY, MOUNTAIN VIEW, CA 94043",,Amphitheatre Parkway,1600,,,,Mountain View,CA,94043,true,zip,,,street,format,false,,,,,,,,,,,,,false,,,,,0,low,,address-formats@2026-09-01;address-rules@2026-09-01,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'
                      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
                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.'
            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, in the partner's own order, for callers that want a "find my address" picker under a postcode field.

        This is the one address answer no open dataset carries: every full address list is derived from a postal operator's own address file, and serving it needs a licence. Until a partner is configured for the country, the endpoint answers `501 ADDRESS_LIST_UNAVAILABLE` and charges nothing, rather than returning an empty list that would read as "there are no addresses here". 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: '2 credits per answered list, which is what the licensed partner charges through. A country with no partner configured answers 501 and costs nothing, and so does a partner that could not answer.'
      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.'
          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: 'No licensed address-list partner is configured for the country. 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, and none is configured for GB.'
                  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/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. Entries untouched for 90 days are re-checked rather than trusted: the lookup keeps the entry while your reports still stand behind it, and drops it once they do not.

        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`. Entries that already exist are never overwritten and are counted as `already_suppressed`. 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
                      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
                      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
                      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 claiming complete coverage 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. `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: 32
                  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: 32
                        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.'
    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
              - tor
              - high_risk
          description: 'The conditions that fired; empty for a clean address.'
        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.'
    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 27 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.'
        deliverable:
          type: string
          enum:
            - deliverable
            - risky
            - undeliverable
          description: 'The verdict. Risky covers a burner, a typo-squat, a web-only domain, a role inbox, a full mailbox, a catch-all, or a mailbox the handshake could not confirm; undeliverable covers bad syntax, an impossible username, no usable mail servers, or a missing or disabled mailbox.'
        reason:
          type:
            - string
            - 'null'
          enum:
            - invalid_syntax
            - invalid_local_part
            - no_mx_records
            - null_mx
            - mx_unresolvable
            - mailbox_not_found
            - mailbox_disabled
            - likely_typo
            - disposable
            - implicit_mx
            - mailbox_full
            - role
            - catch_all
            - unverified
            - suppressed
            - 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: 'A branchable composite with published weights — disposable 80, typo-squat 60, web-only domain 30, role 30, catch-all 30, domain under 30 days old 30, full mailbox 20, gibberish 20, domain under 180 days 10, no SPF on a business domain 10, unverified mailbox 10, plus-tag alias 5. Undeliverable is always 100.'
        risk_level:
          type: string
          enum:
            - low
            - medium
            - high
          description: 'The score bucketed — medium from 30, high from 60.'
        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.'
        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.'
        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.'
        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.'
        role:
          type:
            - boolean
            - 'null'
          description: 'Whether the local part is a role account such as admin@, support@, or billing@.'
        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.'
        catch_all:
          type:
            - boolean
            - 'null'
          description: 'Whether the domain accepts mail for every address, which turns the verdict risky.'
        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 published heuristic, not a probe.'
        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.'
        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.'
        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. 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.'
    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
        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
    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
      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.'
        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.'
        has_spf:
          type:
            - boolean
            - 'null'
        dmarc_policy:
          type:
            - string
            - 'null'
          enum:
            - none
            - quarantine
            - reject
            - null
        domain_registered_at:
          type:
            - string
            - 'null'
          format: date
        domain_age_days:
          type:
            - integer
            - 'null'
        disposable:
          type:
            - boolean
            - '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.'
    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 that suppressed it; null for imports.'
        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 monthly plan while it is paid up (`starter`, `growth`, `scale`, `volume`).'
        credits:
          type: object
          properties:
            balance:
              type: integer
            monthly_grant:
              type: integer
    PhoneFeedbackItem:
      type: object
      required:
        - phone
        - outcome
      properties:
        phone:
          type: string
          maxLength: 32
          description: 'The number, in any common notation.'
        outcome:
          type: string
          enum:
            - delivered
            - undelivered
            - abused
        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.'
    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
        top_undelivered_blocks:
          type: array
          description: 'Up to ten blocks with the most failed deliveries, 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
    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
            - null
          description: "What put it on the list: a block nobody holds (from a lookup), the account's own undelivered or abuse reports; null for imports."
        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
        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, or on the suppression list.'
        unreachable:
          type: integer
          description: 'Numbers the live check found unreachable; only with live_check.'
        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.'
        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.'
    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."
        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
            - null
          description: 'Why it is not valid. `unassigned_block` still carries the parsed number and its block status; 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.'
        line_type_source:
          type:
            - string
            - 'null'
          enum:
            - numbering_plan
            - block_allocation
            - null
          description: "numbering_plan when the metadata decides the type; 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's drama ranges). 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. A listing older than a year scores as disposable_stale instead of disposable, 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
                  - voip
                  - pager
                  - digit_pattern
                  - shared_cost
              weight:
                type: integer
              dataset:
                type: string
        hlr_checked:
          type: boolean
          description: 'Whether a live carrier-network check answered. False until an HLR driver is configured, 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'
        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
        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 range as asked for, digits only.'
        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 numbering metadata names for the range, or the rate centre or exchange area the feed records. A range that serves many towns reads as the first three and how many more.'
        timezones:
          type: array
          items:
            type: string
          description: 'IANA zone names the range falls in.'
        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
        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.'
              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.'
        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.'
    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
        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 45 documented fields of an address validation, 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
            - null
          description: 'Why it cannot stand as written. 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.'
        organization:
          type:
            - string
            - 'null'
          description: 'The company or institution, as sent or as read out of the 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 or private-mailbox number found on the address lines.'
        po_box:
          type:
            - string
            - 'null'
          description: 'The box, route or general-delivery line as it would be written back (PO BOX 1234, POSTFACH 10 64 38).'
        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_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.'
        locality_matches_postcode:
          type:
            - boolean
            - 'null'
          description: 'Whether the town written on the address is the town the directory publishes for that postcode. Null when either is unknown.'
        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 claims to list every address in its country (register_coverage "complete") and does not carry this one; null otherwise, including a register that covers only part of its country.'
        match_level:
          type:
            - string
            - 'null'
          enum:
            - premise
            - street
            - postcode
            - none
            - null
          description: 'How far the address could be followed into the register: to the building, the street, the postcode, or not at all. Null when no register covers the country.'
        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. `partial` is why an address it does not carry answers `exists: null`.'
        latitude:
          type:
            - number
            - 'null'
          description: 'The postcode centroid, not the building: it locates the postcode the address is in.'
        longitude:
          type:
            - number
            - 'null'
        location_source:
          type:
            - string
            - 'null'
          enum:
            - postcode_centroid
            - null
          description: 'What the coordinates describe. Null when there are none.'
        companies_registered:
          type:
            - integer
            - 'null'
          description: 'How many companies the open company registers list at the address. 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. 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
            - null
          description: "Which of the two said so: the operator's own published branches, or a cluster of registered companies at one address."
        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.'
        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
                  - mail_drop
                  - vacant
                  - general_delivery
                  - po_box
                  - terminated_postcode
                  - locality_mismatch
                  - not_in_register
                  - bulk_mail
              weight:
                type: integer
              dataset:
                type: string
        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.'
    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
            - 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.'
    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 no partner configured answers 501 ADDRESS_LIST_UNAVAILABLE instead.'
            addresses:
              type: array
              description: "The addresses at the postcode, in the partner's own order, at most 100."
              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'
                  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: 'This endpoint has no repeat marker, so its meta carries no cache_hit.'
          properties:
            credits_used:
              type: integer
            credits_remaining:
              type: integer
            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
        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
    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
        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.'
    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 claiming complete coverage 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, or only covers part of it.'
        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
      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.'
        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: high when the operator declared the location itself, otherwise 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, when the country or a region known to lie in a single zone settles it. Null for split regions such as Texas rather than a guess.'
        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.'
        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.'
        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 or Fastly when the address is inside that provider's own published ranges; null when the flag came only from the aggregate list."
        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_tor:
          type:
            - boolean
            - 'null'
          description: "From the Tor Project's bulk exit list and its Onionoo relay data, so evaluated for IPv4 and IPv6."
        is_vpn:
          type:
            - boolean
            - 'null'
          description: "Known VPN exits: the server lists of Mullvad, NordVPN and Private Internet Access plus the aggregate X4BNet list. 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 or iCloud Private Relay when a provider's own list names the address; 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.'
        risk_score:
          type:
            - integer
            - 'null'
          description: 'Unallocated 100 + blocklist 80 + Tor 70 + reported_abuse 60 + datacenter 40 + reported_abuse_widely 40 + VPN 30 + relay 10, capped at 100, over the signals actually checked; the datacenter weight is skipped when is_relay or is_crawler is true. Null only when no feed evaluated the address and nobody reported 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.'
          items:
            type: object
            properties:
              signal:
                type: string
                enum:
                  - unallocated
                  - blocklist
                  - tor
                  - reported_abuse
                  - datacenter
                  - reported_abuse_widely
                  - vpn
                  - relay
              weight:
                type: integer
              dataset:
                type: string
        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
        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'
    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
        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
    LedgerEntry:
      type: object
      properties:
        id:
          type: integer
        delta:
          type: integer
          description: 'Signed credit movement.'
        balance_after:
          type: integer
        reason:
          type: string
        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 build or sync date.'
              installed:
                type: boolean
              feeds:
                type:
                  - integer
                  - 'null'
                description: 'For a feed group, how many feeds it aggregates.'
              freshness:
                type: string
                enum:
                  - current
                  - stale
                  - missing
                  - not_dated
                  - unchecked
                description: "Against the dataset's own freshness window: `current` inside it, `stale` past it, `missing` never synced, `not_dated` for a version that carries no date, `unchecked` for data that ships with the app."
              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.'
