{
  "openapi": "3.1.0",
  "info": {
    "title": "DataSkip Skip Tracing API",
    "version": "1.0.0",
    "summary": "Turn a US property address into the owner's name, phones with DNC flags, emails, and mailing address. Flat 4 cents per match, misses free.",
    "description": "DataSkip (https://dataskip.io) is a US skip tracing and property data service. Use this API when you have a property address (or a list of them) and need the owner's contact details: first, last, and full name, up to 10 mobile phones and 9 landlines each with a Do Not Call flag, up to 28 emails, and the mailing address when it differs from the property.\n\nWhen to use which operation:\n- One address: skipTraceSingle (POST /api/v1/skip-trace).\n- Up to 100 addresses in one call: skipTraceBulk (POST /api/v1/skip-trace-bulk).\n- Lists beyond 100 rows, up to 250,000: the CSV job pipeline (createSkipTraceJob, upload, getSkipTraceJob until preview, confirmSkipTraceJob or cancelSkipTraceJob, then download). A job never charges before the previewed cost is confirmed.\n\nPricing: 4 cents per matched lookup at any volume, charged to the account balance. A miss returns HTTP 200 with found: false and costs nothing. No subscription, no minimum order, balances never expire. Bulk calls reserve the worst case up front and refund non-matches automatically, so the net charge is matches only.\n\nAuthentication: create an API key in the dashboard at https://app.dataskip.io (Settings, API) and send it as Authorization: Bearer <key>. Keys start with pc_.\n\nErrors: every error is JSON with an error message. 400 names the invalid field, 401 means a missing, invalid, or revoked key, 402 means the balance cannot cover the request (balanceRequired is in cents), 429 is a rate limit (honor Retry-After, in seconds) or, when the body carries dailyMissLimit, the daily free-miss cap (250 net unmatched addresses per UTC day; four matches forgive one miss), 404 and 409 apply to CSV jobs, 500 is a server error worth retrying later. Call only the documented methods: an unsupported method or an unknown path is answered by the application's own 405 or 404 page, not by an API error body.\n\nRate limits: 500 single lookups and 250 bulk requests per minute per account, 5 contact messages per 15 minutes per IP. A 429 carries Retry-After in seconds.\n\nVersioning and deprecation policy: the API is versioned in the URL path and /api/v1 is the current stable version. Backward-compatible changes (new endpoints, new optional request fields, new response fields) ship in place, so clients must ignore unknown fields. Breaking changes ship as a new path version and never change v1. A retirement is announced at least 6 months ahead; during that notice period every response from the affected version carries a Deprecation header (RFC 9745) and a Sunset header (RFC 8594) with the retirement date plus a Link rel=\"deprecation\" to the announcement, and the dates are published at https://dataskip.io/developers#versioning, in developers.md, and in llms.txt. Nothing is deprecated today.\n\nNot a background check: results cover property ownership and contact information only, never criminal, credit, or employment records. US properties only, all 50 states. Input is the address; the owner's name is never required and cannot be searched on its own.\n\nThe official npm package skiptrace (CLI plus typed JS/TS SDK, https://www.npmjs.com/package/skiptrace) wraps the same endpoints: npm install -g skiptrace. Human docs: https://dataskip.io/developers. Markdown mirror: https://dataskip.io/developers.md. API catalog (RFC 9727): https://dataskip.io/.well-known/api-catalog. Site summary for agents: https://dataskip.io/llms.txt.",
    "termsOfService": "https://dataskip.io/terms",
    "contact": {
      "name": "DataSkip support",
      "url": "https://dataskip.io/contact",
      "email": "support@dataskip.io"
    },
    "license": {
      "name": "API terms",
      "url": "https://dataskip.io/terms"
    }
  },
  "externalDocs": {
    "description": "Skip tracing API documentation (CLI, SDK, REST)",
    "url": "https://dataskip.io/developers"
  },
  "servers": [
    {
      "url": "https://app.dataskip.io",
      "description": "Production"
    }
  ],
  "tags": [
    {
      "name": "Skip tracing",
      "description": "Address-in, owner-contact-out lookups billed per match. Single lookups and batches of up to 100."
    },
    {
      "name": "CSV jobs",
      "description": "Bulk skip tracing of a whole CSV (up to 250,000 rows) with a cost preview before anything is charged. The same pipeline the dashboard and the skiptrace CLI use."
    },
    {
      "name": "Support",
      "description": "Contact DataSkip support. No authentication."
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "paths": {
    "/api/v1/skip-trace": {
      "post": {
        "tags": ["Skip tracing"],
        "operationId": "skipTraceSingle",
        "summary": "Skip trace one property address",
        "description": "Look up the owner of one US property address. Returns the owner's name, phones with DNC flags, emails, and mailing address. A match charges 4 cents (the charged field, in cents); a miss returns HTTP 200 with found: false and contact: null and costs nothing. Only address is required; city, state, and zip improve match accuracy. Rate limit: 500 lookups per minute per account. For lists, use skipTraceBulk or the CSV job pipeline instead of looping this endpoint.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AddressInput"
              },
              "example": {
                "address": "44 Pine St",
                "city": "Bridgewater",
                "state": "MA",
                "zip": "02324"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Lookup ran. Check found: true is a match (charged 4 cents), false is a free miss.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SkipTraceResponse"
                },
                "examples": {
                  "match": {
                    "summary": "Match (charged 4 cents)",
                    "value": {
                      "success": true,
                      "found": true,
                      "charged": 4,
                      "contact": {
                        "firstName": "Jane",
                        "lastName": "Carter",
                        "fullName": "Jane Carter",
                        "propertyAddress": "44 Pine St",
                        "propertyCity": "Bridgewater",
                        "propertyState": "MA",
                        "propertyZip": "02324",
                        "mailingAddress": "44 Pine St",
                        "mailingCity": "Bridgewater",
                        "mailingState": "MA",
                        "mailingZip": "02324"
                      },
                      "phones": [
                        {
                          "number": "5085550182",
                          "type": "mobile",
                          "dnc": false
                        },
                        {
                          "number": "5085554415",
                          "type": "landline",
                          "dnc": true
                        }
                      ],
                      "emails": ["jane@example.com"]
                    }
                  },
                  "miss": {
                    "summary": "Miss (free)",
                    "value": {
                      "success": true,
                      "found": false,
                      "contact": null
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/v1/skip-trace-bulk": {
      "post": {
        "tags": ["Skip tracing"],
        "operationId": "skipTraceBulk",
        "summary": "Skip trace up to 100 addresses in one request",
        "description": "Look up 1 to 100 property addresses in one call. Results come back in request order, each with the single-lookup shape. Pricing matches single lookups: 4 cents per match, misses free. The worst case (every entry matching) is reserved from the balance up front and non-matches are refunded automatically when the request completes, so the net charge is matches only; both entries appear in the account's transaction history. A 402 with balanceRequired means the balance cannot cover the whole batch at the per-lookup rate. Rate limit: 250 requests per minute per account.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BulkSkipTraceRequest"
              },
              "example": {
                "addresses": [
                  {
                    "address": "123 Sample Ave",
                    "city": "Anytown",
                    "state": "CA",
                    "zip": "90210"
                  },
                  {
                    "address": "456 Demo Blvd",
                    "zip": "90211"
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "All lookups ran. totalCharged is the net charge in cents (matches only).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BulkSkipTraceResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/user/skip-trace": {
      "post": {
        "tags": ["CSV jobs"],
        "operationId": "createSkipTraceJob",
        "summary": "Create a CSV skip trace job",
        "description": "Step 1 of the CSV pipeline. Registers a job for one CSV file (up to 250,000 rows) and returns where to upload it. The columnMapping names the CSV header for each field; address, city, state, and zip must all be mapped, and firstName and lastName columns are optional. uploadMethod tells you how to upload: presigned means PUT the raw CSV to uploadUrl with Content-Type: text/csv and then call markSkipTraceJobUploaded; direct means POST the file as multipart form data (field name file) to uploadUrl. Nothing is charged at this step.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateSkipTraceJobRequest"
              },
              "example": {
                "filename": "leads.csv",
                "columnMapping": {
                  "address": "Property Address",
                  "city": "City",
                  "state": "State",
                  "zip": "Zip"
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Job created. Upload the CSV next.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreateSkipTraceJobResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "get": {
        "tags": ["CSV jobs"],
        "operationId": "listSkipTraceJobs",
        "summary": "List the account's 50 most recent CSV jobs",
        "description": "Returns the 50 most recent jobs, newest first, with their status, row counts, charges, and a downloadUrl for completed jobs.",
        "responses": {
          "200": {
            "description": "Job list.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["jobs"],
                  "properties": {
                    "jobs": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/SkipTraceJob"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/user/skip-trace/{jobId}": {
      "get": {
        "tags": ["CSV jobs"],
        "operationId": "getSkipTraceJob",
        "parameters": [
          {
            "name": "jobId",
            "in": "path",
            "required": true,
            "description": "The jobId returned by createSkipTraceJob.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "summary": "Get a CSV job's status, counts, and download URL",
        "description": "Poll this after uploading. When status is preview the job reports totalRows, matchedRows, and totalChargeCents (matched rows times 4 cents) so the cost can be confirmed or the job cancelled. When status is completed, downloadUrl points at the enriched CSV (matched rows gain owner names, phones, and emails; unmatched rows come back unchanged); an expired download link is refreshed on each call. Poll every few seconds; this read does not count as API key usage.",
        "responses": {
          "200": {
            "description": "Job detail.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SkipTraceJob"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/user/skip-trace/{jobId}/upload": {
      "post": {
        "tags": ["CSV jobs"],
        "operationId": "uploadSkipTraceJobFile",
        "parameters": [
          {
            "name": "jobId",
            "in": "path",
            "required": true,
            "description": "The jobId returned by createSkipTraceJob.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "summary": "Upload the CSV directly (uploadMethod: direct)",
        "description": "Step 2 when createSkipTraceJob returned uploadMethod: direct. Send the CSV as multipart form data in the file field (50 MB maximum). The job must be in pending status. Processing starts automatically; poll getSkipTraceJob until preview.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": ["file"],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "The CSV file, 50 MB maximum."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "File stored; processing started.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobActionResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/user/skip-trace/{jobId}/uploaded": {
      "post": {
        "tags": ["CSV jobs"],
        "operationId": "markSkipTraceJobUploaded",
        "parameters": [
          {
            "name": "jobId",
            "in": "path",
            "required": true,
            "description": "The jobId returned by createSkipTraceJob.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "summary": "Report that a presigned upload finished (uploadMethod: presigned)",
        "description": "Step 2 when createSkipTraceJob returned uploadMethod: presigned. Call this after the PUT to uploadUrl succeeds. The job must be in pending status. Processing starts automatically; poll getSkipTraceJob until preview.",
        "responses": {
          "200": {
            "description": "Upload acknowledged; processing started.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobActionResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/user/skip-trace/{jobId}/confirm": {
      "post": {
        "tags": ["CSV jobs"],
        "operationId": "confirmSkipTraceJob",
        "parameters": [
          {
            "name": "jobId",
            "in": "path",
            "required": true,
            "description": "The jobId returned by createSkipTraceJob.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "summary": "Confirm the previewed cost and charge the balance",
        "description": "Step 3. Charges totalChargeCents exactly as previewed (matched rows only, 4 cents each) and starts generating the enriched CSV. The job must be in preview status. Poll getSkipTraceJob until completed, then download from downloadUrl.",
        "responses": {
          "200": {
            "description": "Charged and confirmed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobActionResponse"
                },
                "example": {
                  "status": "confirmed",
                  "jobId": "0b0f6c5e-3e4a-4d8b-9f0a-2f1c6d7e8a9b",
                  "totalChargeCents": 3956
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/user/skip-trace/{jobId}/cancel": {
      "post": {
        "tags": ["CSV jobs"],
        "operationId": "cancelSkipTraceJob",
        "parameters": [
          {
            "name": "jobId",
            "in": "path",
            "required": true,
            "description": "The jobId returned by createSkipTraceJob.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "summary": "Cancel a job before confirming it",
        "description": "Cancels a job in pending, uploaded, or preview status. Nothing is charged. Confirmed or completed jobs cannot be cancelled (409).",
        "responses": {
          "200": {
            "description": "Cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobActionResponse"
                },
                "example": {
                  "status": "cancelled",
                  "jobId": "0b0f6c5e-3e4a-4d8b-9f0a-2f1c6d7e8a9b"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/contact": {
      "post": {
        "tags": ["Support"],
        "operationId": "sendContactMessage",
        "summary": "Send a message to DataSkip support",
        "description": "Public endpoint behind the https://dataskip.io/contact form; no authentication. Map the user's intent to the closest reason: login or access problems are Login Issues, questions and requests are General, everything else is Other. Use the user's real email (it becomes the reply-to) and phone; never invent values. Rate limit: 5 messages per 15 minutes per IP. Only treat the message as sent when the 200 body is {\"success\": true}.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ContactRequest"
              },
              "example": {
                "fullName": "Jane Doe",
                "email": "jane@example.com",
                "phone": "(555) 123-4567",
                "reason": "General",
                "message": "Do you support a 200,000 row list in one run?",
                "consent": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Message sent.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["success"],
                  "properties": {
                    "success": {
                      "type": "boolean",
                      "const": true
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "A field is missing or invalid; error names it. Fix that field and retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "Message must be at least 5 characters"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "API key as a Bearer token: Authorization: Bearer pc_... Create keys in the dashboard at https://app.dataskip.io under Settings, API."
      }
    },
    "responses": {
      "BadRequest": {
        "description": "The request body is not valid JSON or a field is missing or invalid; error says which.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "address is required and must be a non-empty string"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing, malformed, invalid, or revoked API key, or the account is not active.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "Invalid or revoked API key"
            }
          }
        }
      },
      "PaymentRequired": {
        "description": "The account balance cannot cover the request. balanceRequired is the amount needed in cents (the full worst case for bulk requests). Top up at https://app.dataskip.io and retry.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "Insufficient balance",
              "balanceRequired": 4
            }
          }
        }
      },
      "NotFound": {
        "description": "No job with that jobId belongs to this account.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "Job not found"
            }
          }
        }
      },
      "Conflict": {
        "description": "The job is not in a status that allows this action; error names the current status.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "Cannot confirm: job is in \"completed\" status"
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "Rate limited: wait for the Retry-After seconds and retry. Limits: 500 single lookups and 250 bulk requests per minute per account, 5 contact messages per 15 minutes per IP. When the body carries dailyMissLimit instead, the account hit the daily free-miss cap (250 net unmatched addresses per UTC day, four matches forgive one miss); check the list for formatting or accuracy problems and retry the next day.",
        "headers": {
          "Retry-After": {
            "description": "Seconds to wait before retrying (present on rate limits).",
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "rateLimit": {
                "summary": "Rate limit",
                "value": {
                  "error": "Too many requests. Please try again later."
                }
              },
              "dailyMissLimit": {
                "summary": "Daily free-miss cap",
                "value": {
                  "error": "Too many unmatched addresses today. Please check your list for formatting or accuracy issues and try again tomorrow.",
                  "dailyMissLimit": 250
                }
              }
            }
          }
        }
      },
      "ServerError": {
        "description": "Unexpected server error. Nothing was charged for a failed lookup; retry later.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "Internal server error"
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "description": "Every error response has this shape.",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "string",
            "description": "Human-readable message naming the problem (and the invalid field on 400)."
          },
          "balanceRequired": {
            "type": "integer",
            "description": "On 402: the balance needed in cents to run the request."
          },
          "dailyMissLimit": {
            "type": "integer",
            "description": "On 429: present when the daily free-miss cap was hit rather than the per-minute rate limit."
          }
        }
      },
      "AddressInput": {
        "type": "object",
        "description": "A US property address. Only address is required; city, state, and zip improve match accuracy. The owner's name is never required.",
        "required": ["address"],
        "properties": {
          "address": {
            "type": "string",
            "minLength": 1,
            "description": "Street address, for example 44 Pine St."
          },
          "city": {
            "type": "string",
            "description": "City name."
          },
          "state": {
            "type": "string",
            "description": "Two-letter state code, for example MA."
          },
          "zip": {
            "type": "string",
            "description": "5-digit ZIP code."
          }
        }
      },
      "Contact": {
        "type": "object",
        "description": "The matched owner. Any field can be null when that detail is not on file.",
        "properties": {
          "firstName": { "type": ["string", "null"] },
          "lastName": { "type": ["string", "null"] },
          "fullName": { "type": ["string", "null"] },
          "propertyAddress": { "type": ["string", "null"] },
          "propertyCity": { "type": ["string", "null"] },
          "propertyState": { "type": ["string", "null"] },
          "propertyZip": { "type": ["string", "null"] },
          "mailingAddress": {
            "type": ["string", "null"],
            "description": "Owner's mailing address; differs from the property address for absentee owners."
          },
          "mailingCity": { "type": ["string", "null"] },
          "mailingState": { "type": ["string", "null"] },
          "mailingZip": { "type": ["string", "null"] }
        }
      },
      "Phone": {
        "type": "object",
        "required": ["number", "type", "dnc"],
        "properties": {
          "number": {
            "type": "string",
            "description": "Phone number."
          },
          "type": {
            "type": "string",
            "enum": ["mobile", "landline"]
          },
          "dnc": {
            "type": "boolean",
            "description": "True when the number is on a Do Not Call registry. Check before cold calling (TCPA)."
          }
        }
      },
      "SkipTraceResponse": {
        "type": "object",
        "description": "Result of one lookup. found: true is a match: charged, contact, phones (up to 10 mobile and 9 landline numbers), and emails (up to 28) are present. found: false is a free miss: contact is null and charged, phones, and emails are absent.",
        "required": ["success", "found", "contact"],
        "properties": {
          "success": { "type": "boolean", "const": true },
          "found": {
            "type": "boolean",
            "description": "True when an owner was matched and charged, false for a free miss."
          },
          "charged": {
            "type": "integer",
            "description": "Cents charged for this lookup (4). Present only on a match."
          },
          "contact": {
            "anyOf": [
              { "$ref": "#/components/schemas/Contact" },
              { "type": "null" }
            ],
            "description": "The matched owner, or null on a miss."
          },
          "phones": {
            "type": "array",
            "description": "Present only on a match.",
            "items": {
              "$ref": "#/components/schemas/Phone"
            }
          },
          "emails": {
            "type": "array",
            "description": "Present only on a match.",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "BulkSkipTraceRequest": {
        "type": "object",
        "required": ["addresses"],
        "properties": {
          "addresses": {
            "type": "array",
            "minItems": 1,
            "maxItems": 100,
            "items": {
              "$ref": "#/components/schemas/AddressInput"
            }
          }
        }
      },
      "BulkLookupResult": {
        "type": "object",
        "description": "One entry of a bulk response, in request order. A miss has found: false, charged: 0, contact: null, and empty phones and emails.",
        "required": ["found", "charged", "contact", "phones", "emails"],
        "properties": {
          "found": { "type": "boolean" },
          "charged": {
            "type": "integer",
            "description": "Cents charged for this entry: 4 on a match, 0 on a miss."
          },
          "contact": {
            "anyOf": [
              { "$ref": "#/components/schemas/Contact" },
              { "type": "null" }
            ]
          },
          "phones": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Phone"
            }
          },
          "emails": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "BulkSkipTraceResponse": {
        "type": "object",
        "required": ["success", "total", "matched", "totalCharged", "results"],
        "properties": {
          "success": { "type": "boolean", "const": true },
          "total": {
            "type": "integer",
            "description": "Number of addresses submitted."
          },
          "matched": {
            "type": "integer",
            "description": "Number of addresses that matched."
          },
          "totalCharged": {
            "type": "integer",
            "description": "Net charge in cents (matched times 4)."
          },
          "results": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/BulkLookupResult"
            }
          }
        }
      },
      "CreateSkipTraceJobRequest": {
        "type": "object",
        "required": ["filename", "columnMapping"],
        "properties": {
          "filename": {
            "type": "string",
            "maxLength": 500,
            "description": "Name of the CSV you are about to upload; must end in .csv."
          },
          "columnMapping": {
            "type": "object",
            "description": "CSV header name for each field. address, city, state, and zip are all required; firstName and lastName are optional owner-name columns that steer which person's contacts come back.",
            "required": ["address", "city", "state", "zip"],
            "properties": {
              "address": { "type": "string" },
              "city": { "type": "string" },
              "state": { "type": "string" },
              "zip": { "type": "string" },
              "firstName": { "type": "string" },
              "lastName": { "type": "string" }
            }
          }
        }
      },
      "CreateSkipTraceJobResponse": {
        "type": "object",
        "required": ["jobId", "uploadUrl", "uploadMethod"],
        "properties": {
          "jobId": {
            "type": "string",
            "format": "uuid"
          },
          "uploadUrl": {
            "type": "string",
            "description": "Where to send the CSV. A presigned URL to PUT the raw file to, or the direct upload endpoint to POST multipart form data to."
          },
          "uploadMethod": {
            "type": "string",
            "enum": ["presigned", "direct"],
            "description": "presigned: PUT the file to uploadUrl with Content-Type: text/csv, then call markSkipTraceJobUploaded. direct: POST multipart form data (field file) to uploadUrl."
          }
        }
      },
      "JobStatus": {
        "type": "string",
        "description": "Lifecycle: pending (created, awaiting upload), uploaded, processing (matching rows), preview (cost ready to confirm), confirmed (charged), generating (building the enriched CSV), completed (downloadUrl ready), failed, cancelled.",
        "enum": [
          "pending",
          "uploaded",
          "processing",
          "preview",
          "confirmed",
          "generating",
          "completed",
          "failed",
          "cancelled"
        ]
      },
      "SkipTraceJob": {
        "type": "object",
        "required": ["id", "status", "filename", "createdAt", "updatedAt"],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "$ref": "#/components/schemas/JobStatus"
          },
          "filename": {
            "type": "string"
          },
          "totalRows": {
            "type": ["integer", "null"],
            "description": "Rows in the uploaded CSV (known from preview onward)."
          },
          "matchedRows": {
            "type": ["integer", "null"],
            "description": "Rows that matched an owner; the only rows billed."
          },
          "unmatchedRows": {
            "type": ["integer", "null"]
          },
          "pricePerRowCents": {
            "type": "integer",
            "description": "Rate applied per matched row, in cents (4)."
          },
          "totalChargeCents": {
            "type": ["integer", "null"],
            "description": "matchedRows times pricePerRowCents; what confirmSkipTraceJob charges."
          },
          "refundCents": {
            "type": ["integer", "null"]
          },
          "nonMatchCount": {
            "type": ["integer", "null"]
          },
          "blankAddressCount": {
            "type": ["integer", "null"],
            "description": "Rows skipped for having no address (job detail only)."
          },
          "origin": {
            "type": "string",
            "description": "Where the job came from (job list only), for example upload."
          },
          "areaZip5": {
            "type": ["string", "null"],
            "description": "ZIP code for data purchases by ZIP (job list only)."
          },
          "downloadUrl": {
            "type": ["string", "null"],
            "description": "Link to the enriched CSV; only set while status is completed."
          },
          "errorMessage": {
            "type": ["string", "null"]
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "JobActionResponse": {
        "type": "object",
        "required": ["status", "jobId"],
        "properties": {
          "status": {
            "$ref": "#/components/schemas/JobStatus"
          },
          "jobId": {
            "type": "string",
            "format": "uuid"
          },
          "totalChargeCents": {
            "type": "integer",
            "description": "Present on confirm: the amount charged, in cents."
          }
        }
      },
      "ContactRequest": {
        "type": "object",
        "required": ["fullName", "email", "phone", "reason", "message"],
        "properties": {
          "fullName": {
            "type": "string",
            "minLength": 2
          },
          "email": {
            "type": "string",
            "format": "email",
            "description": "Valid email; used as the reply-to address."
          },
          "phone": {
            "type": "string",
            "description": "US phone (10 digits, or 11 with a leading 1) or E.164 with country code; formatting characters are allowed."
          },
          "reason": {
            "type": "string",
            "enum": ["Login Issues", "General", "Other"]
          },
          "message": {
            "type": "string",
            "minLength": 5
          },
          "consent": {
            "type": "boolean",
            "description": "Whether the sender agreed to be contacted by email or phone about the request."
          }
        }
      }
    }
  }
}
