{
  "openapi": "3.1.0",
  "info": {
    "title": "Dedupe.dev API",
    "version": "1.0.0",
    "summary": "Remove exact duplicates from text lines, CSV rows, or a JSON array over HTTP.",
    "description": "One endpoint. POST a JSON body describing a mode, the data, and options; get the deduplicated result back in the same response. The first occurrence of a duplicate is kept and the original order is preserved.\n\nUsage is metered in billable units, not calls: `units = max(rows, ceil(bytes / 1024))`. Requests without a key are served on a per-IP daily budget of 500 units with a 256 KB body cap. A free key raises that to 1,000 units a month, Starter to 100,000, Pro to 300,000.\n\nNothing sent to this endpoint is stored. Matching is exact by default; `rows` mode also offers fuzzy matching (`options.fuzzy`, similarity threshold, capped at 50,000 data rows per request, API key required).",
    "contact": {
      "name": "Dedupe.dev",
      "url": "https://dedupe.dev/api"
    },
    "license": {
      "name": "Proprietary",
      "identifier": "LicenseRef-Proprietary"
    }
  },
  "servers": [
    {
      "url": "https://dedupe.dev",
      "description": "Production"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    },
    {
      "apiKeyAuth": []
    },
    {}
  ],
  "externalDocs": {
    "description": "Full reference in Markdown",
    "url": "https://dedupe.dev/api.md"
  },
  "paths": {
    "/api/dedupe": {
      "post": {
        "operationId": "dedupe",
        "summary": "Deduplicate lines, CSV rows, or a JSON array",
        "description": "Checks run in this order and the first failure is returned: key verification (401), declared body size (413), JSON parse and mode validation (400), per-request unit cap (413), monthly unit quota (429), burst rate limit (429).",
        "tags": [
          "dedupe"
        ],
        "security": [
          {
            "bearerAuth": []
          },
          {
            "apiKeyAuth": []
          },
          {}
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DedupeRequest"
              },
              "examples": {
                "lines": {
                  "summary": "Text lines, trimmed and compared case-insensitively",
                  "value": {
                    "mode": "lines",
                    "data": "a\nb\na",
                    "options": {
                      "trim": true,
                      "ignoreCase": true
                    }
                  }
                },
                "rows": {
                  "summary": "CSV deduplicated on the first column",
                  "value": {
                    "mode": "rows",
                    "data": "email,name\na@x.com,Ann\na@x.com,Ann B",
                    "options": {
                      "keyColumns": [
                        0
                      ],
                      "hasHeader": true
                    }
                  }
                },
                "json": {
                  "summary": "JSON array deduplicated on a dot path",
                  "value": {
                    "mode": "json",
                    "data": [
                      {
                        "user": {
                          "id": 1
                        }
                      },
                      {
                        "user": {
                          "id": 1
                        }
                      },
                      {
                        "user": {
                          "id": 2
                        }
                      }
                    ],
                    "options": {
                      "key": "user.id"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Deduplicated result. The shape of `result` follows the request mode.",
            "headers": {
              "Access-Control-Allow-Origin": {
                "schema": {
                  "type": "string",
                  "const": "*"
                }
              },
              "Access-Control-Allow-Methods": {
                "schema": {
                  "type": "string",
                  "const": "POST, OPTIONS"
                }
              },
              "Access-Control-Allow-Headers": {
                "schema": {
                  "type": "string",
                  "const": "Content-Type"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DedupeResponse"
                },
                "examples": {
                  "lines": {
                    "value": {
                      "result": [
                        "a",
                        "b"
                      ],
                      "kept": 2,
                      "removed": 1
                    }
                  },
                  "rows": {
                    "value": {
                      "result": "email,name\na@x.com,Ann",
                      "kept": 1,
                      "removed": 1
                    }
                  },
                  "json": {
                    "value": {
                      "result": [
                        {
                          "user": {
                            "id": 1
                          }
                        },
                        {
                          "user": {
                            "id": 2
                          }
                        }
                      ],
                      "kept": 2,
                      "removed": 1
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The body is not JSON, or `mode`, `data` and `options` do not agree.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "invalid-json": {
                    "summary": "The request body is not parseable JSON.",
                    "value": {
                      "error": "invalid JSON body"
                    }
                  },
                  "bad-mode": {
                    "summary": "mode is missing, or is not one of the three modes.",
                    "value": {
                      "error": "mode must be one of: lines, rows, json"
                    }
                  },
                  "lines-data": {
                    "summary": "mode is lines but data is not a string.",
                    "value": {
                      "error": "lines mode expects data: string"
                    }
                  },
                  "rows-data": {
                    "summary": "mode is rows but data is not a string.",
                    "value": {
                      "error": "rows mode expects data: CSV string"
                    }
                  },
                  "json-data": {
                    "summary": "mode is json but data is not an array.",
                    "value": {
                      "error": "json mode expects data: array"
                    }
                  },
                  "fuzzy-mode": {
                    "summary": "options.fuzzy is set on a lines or json request. Fuzzy matching compares whole normalized records, which only means something for tabular data, so it is a rows-mode option.",
                    "value": {
                      "error": "fuzzy matching is available in rows mode only"
                    }
                  },
                  "bad-threshold": {
                    "summary": "options.threshold is present but is not a number between 0.5 and 1.",
                    "value": {
                      "error": "threshold must be a number between 0.5 and 1"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "The presented key is unknown, revoked, malformed, or unverifiable. Verification fails closed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "invalid-key": {
                    "summary": "The presented key is unknown, revoked, malformed, or could not be verified. Verification fails closed, so an unverifiable key is treated as an invalid one.",
                    "value": {
                      "error": "invalid api key"
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "A keyless request asked for fuzzy matching, which requires an API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "fuzzy-keyless": {
                    "summary": "A keyless request asked for fuzzy matching. Fuzzy is the expensive pairwise path and needs an accountable caller: any key works, including the free tier's.",
                    "value": {
                      "error": "fuzzy matching requires an API key — create a free key at https://dedupe.dev/app/keys"
                    }
                  }
                }
              }
            }
          },
          "405": {
            "description": "Method other than POST or OPTIONS.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "method": {
                    "summary": "The method is not POST or OPTIONS. GET, PUT, PATCH and DELETE all answer with this.",
                    "value": {
                      "error": "POST a JSON body — see https://dedupe.dev/api"
                    }
                  }
                }
              }
            }
          },
          "413": {
            "description": "Over the plan's body-size cap (bytes), the fuzzy row cap, or the per-request unit cap (rows).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "size-keyless": {
                    "summary": "A keyless request is over the 256 KB keyless body cap.",
                    "value": {
                      "error": "payload too large (256 KB plan limit)"
                    }
                  },
                  "size-keyed": {
                    "summary": "A keyed request declares a Content-Length over the plan's body cap. Refused before the body is read, so it never buffers. The same cap is re-checked after reading, and that second check reports payload too large (512 KB plan limit) without the plan name.",
                    "value": {
                      "error": "payload too large (512 KB Free plan limit)"
                    }
                  },
                  "fuzzy-cap": {
                    "summary": "A fuzzy request carries more than 50,000 data rows. That is the same ceiling as the paid plans' per-request unit cap, so in practice the plan cap is what you hit first. The number in the message is your row count.",
                    "value": {
                      "error": "fuzzy matching is capped at 50,000 rows per request (got 50,500); split the file or use exact matching"
                    }
                  },
                  "units": {
                    "summary": "The request parsed fine but costs more units than the plan allows in one call. A distinct message from the size cap: this one is about row count, not bytes.",
                    "value": {
                      "error": "request too large: 812 units, over the 200-unit Free per-request limit",
                      "units": 812,
                      "limit": 200
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "Monthly unit quota spent, burst rate limit hit, or the keyless per-IP daily budget spent.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "monthly": {
                    "summary": "This request would take the account past its monthly unit allowance. No Retry-After: resets is the ISO timestamp the allowance refills at.",
                    "value": {
                      "error": "monthly unit limit reached",
                      "limit": 1000,
                      "used": 987,
                      "resets": "2026-08-01T00:00:00.000Z"
                    }
                  },
                  "burst": {
                    "summary": "The key exceeded its burst rate limit. Sent with a Retry-After header in seconds.",
                    "value": {
                      "error": "rate limit reached: 60 requests per hour on Free",
                      "limit": 60,
                      "windowSeconds": 3600,
                      "retryAfter": 42
                    }
                  },
                  "keyless-daily": {
                    "summary": "A keyless caller spent its 500-unit daily budget for that IP. No Retry-After.",
                    "value": {
                      "error": "keyless daily unit limit reached — create a free API key at https://dedupe.dev/app/keys",
                      "limit": 500,
                      "units": 12
                    }
                  }
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Whole seconds until the burst window resets, never less than 1. Sent ONLY for the burst rate limit. A monthly-quota 429 has no Retry-After and carries `resets` in the body instead.",
                "required": false,
                "schema": {
                  "type": "integer",
                  "minimum": 1
                }
              }
            }
          }
        }
      },
      "options": {
        "operationId": "dedupePreflight",
        "summary": "CORS preflight",
        "description": "Returns 204 with the CORS headers and no body.",
        "tags": [
          "dedupe"
        ],
        "security": [
          {}
        ],
        "responses": {
          "204": {
            "description": "No content.",
            "headers": {
              "Access-Control-Allow-Origin": {
                "schema": {
                  "type": "string",
                  "const": "*"
                }
              },
              "Access-Control-Allow-Methods": {
                "schema": {
                  "type": "string",
                  "const": "POST, OPTIONS"
                }
              },
              "Access-Control-Allow-Headers": {
                "schema": {
                  "type": "string",
                  "const": "Content-Type"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "`Authorization: Bearer dd_live_...`. Read first. Create keys at https://dedupe.dev/app/keys; the token is shown once and only its SHA-256 hash is stored."
      },
      "apiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "x-api-key",
        "description": "The same token, for clients that cannot set an Authorization header."
      }
    },
    "schemas": {
      "DedupeRequest": {
        "title": "DedupeRequest",
        "description": "One of three shapes, selected by `mode`.",
        "oneOf": [
          {
            "$ref": "#/components/schemas/LinesRequest"
          },
          {
            "$ref": "#/components/schemas/RowsRequest"
          },
          {
            "$ref": "#/components/schemas/JsonRequest"
          }
        ]
      },
      "LinesRequest": {
        "type": "object",
        "required": [
          "mode",
          "data"
        ],
        "properties": {
          "mode": {
            "const": "lines"
          },
          "data": {
            "type": "string",
            "description": "Text split on \\r\\n, \\n or \\r. One billable row per line."
          },
          "options": {
            "$ref": "#/components/schemas/LinesOptions"
          }
        }
      },
      "RowsRequest": {
        "type": "object",
        "required": [
          "mode",
          "data"
        ],
        "properties": {
          "mode": {
            "const": "rows"
          },
          "data": {
            "type": "string",
            "description": "A CSV string, not an array. Comma delimiter only, double-quote quoting, leading UTF-8 BOM stripped. Tab- and semicolon-separated input is not parsed as such."
          },
          "options": {
            "$ref": "#/components/schemas/RowsOptions"
          }
        }
      },
      "JsonRequest": {
        "type": "object",
        "required": [
          "mode",
          "data"
        ],
        "properties": {
          "mode": {
            "const": "json"
          },
          "data": {
            "type": "array",
            "items": {},
            "description": "Array of anything. One billable row per item."
          },
          "options": {
            "$ref": "#/components/schemas/JsonOptions"
          }
        }
      },
      "LinesOptions": {
        "type": "object",
        "properties": {
          "trim": {
            "type": "boolean",
            "default": false,
            "description": "Trim each line before comparing; the trimmed line is returned."
          },
          "ignoreCase": {
            "type": "boolean",
            "default": false,
            "description": "Compare case-insensitively; the first spelling seen is returned."
          },
          "dropEmpty": {
            "type": "boolean",
            "default": false,
            "description": "Discard empty lines. Applied after trim."
          }
        }
      },
      "RowsOptions": {
        "type": "object",
        "properties": {
          "keyColumns": {
            "type": "array",
            "items": {
              "type": "integer",
              "minimum": 0
            },
            "default": [],
            "description": "Zero-based columns that define a duplicate. Empty compares the whole row."
          },
          "hasHeader": {
            "type": "boolean",
            "default": true,
            "description": "First row is a header: never deduplicated, returned first, and not billed."
          },
          "caseSensitive": {
            "type": "boolean",
            "default": true,
            "description": "false compares keys case-insensitively; rows are returned unchanged. Ignored when fuzzy is on."
          },
          "fuzzy": {
            "type": "boolean",
            "default": false,
            "description": "Fuzzy matching: keys are normalized (case, punctuation, word order) and compared by edit distance. Requires an API key (any tier). At most 50,000 data rows per request."
          },
          "threshold": {
            "type": "number",
            "minimum": 0.5,
            "maximum": 1,
            "default": 0.9,
            "description": "Similarity two keys must reach to count as duplicates. 1 is exact-after-normalization."
          }
        }
      },
      "JsonOptions": {
        "type": "object",
        "properties": {
          "key": {
            "type": "string",
            "default": "",
            "description": "Dot path to the identifying value, e.g. user.id. Empty compares the whole item by its JSON serialisation, so key order matters."
          }
        }
      },
      "DedupeResponse": {
        "title": "DedupeResponse",
        "oneOf": [
          {
            "$ref": "#/components/schemas/LinesResponse"
          },
          {
            "$ref": "#/components/schemas/RowsResponse"
          },
          {
            "$ref": "#/components/schemas/JsonResponse"
          }
        ]
      },
      "LinesResponse": {
        "type": "object",
        "required": [
          "result",
          "kept",
          "removed"
        ],
        "properties": {
          "result": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "kept": {
            "type": "integer",
            "minimum": 0,
            "description": "Items that survived deduplication."
          },
          "removed": {
            "type": "integer",
            "minimum": 0,
            "description": "Items dropped as duplicates of an earlier one."
          }
        }
      },
      "RowsResponse": {
        "type": "object",
        "required": [
          "result",
          "kept",
          "removed"
        ],
        "properties": {
          "result": {
            "type": "string",
            "description": "Re-serialised CSV, header included when hasHeader is true. Rows joined with \\n."
          },
          "kept": {
            "type": "integer",
            "minimum": 0,
            "description": "Items that survived deduplication."
          },
          "removed": {
            "type": "integer",
            "minimum": 0,
            "description": "Items dropped as duplicates of an earlier one."
          }
        }
      },
      "JsonResponse": {
        "type": "object",
        "required": [
          "result",
          "kept",
          "removed"
        ],
        "properties": {
          "result": {
            "type": "array",
            "items": {}
          },
          "kept": {
            "type": "integer",
            "minimum": 0,
            "description": "Items that survived deduplication."
          },
          "removed": {
            "type": "integer",
            "minimum": 0,
            "description": "Items dropped as duplicates of an earlier one."
          }
        }
      },
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "description": "Every non-2xx response. Some carry extra fields: 413 unit errors add `units` and `limit`; the monthly 429 adds `limit`, `used` and `resets`; the burst 429 adds `limit`, `windowSeconds` and `retryAfter`; the keyless 429 adds `limit` and `units`.",
        "properties": {
          "error": {
            "type": "string"
          },
          "limit": {
            "type": "integer"
          },
          "used": {
            "type": "integer"
          },
          "units": {
            "type": "integer"
          },
          "windowSeconds": {
            "type": "integer"
          },
          "retryAfter": {
            "type": "integer"
          },
          "resets": {
            "type": "string",
            "format": "date-time"
          }
        }
      }
    }
  }
}
