Detect duplicate images by hash
If you already compute perceptual hashes for your images, this endpoint turns that flat list into duplicate groups.
Run — free
You send one entry per image — a name and its hash, as a hexadecimal string or a 0/1 bitstring — plus a maximum Hamming distance in bits. Every pair of hashes within that distance is linked, and the linked pairs are merged into connected groups, so a near-duplicate chain collapses into a single cluster instead of overlapping pairs. The result lists each group with its members, the internal pairwise distances and the largest distance inside the group, together with totals: how many images are duplicates and how many remain unique. Nothing is uploaded: the hashes you already have are all the input it needs.
From a list of hashes to duplicate groups
A perceptual hash such as a 64-bit pHash encodes what an image looks like, so two photos that differ only in compression, size or a watermark produce hashes that differ in just a few bits. Comparing those hashes pairwise is easy; the annoying part is what comes next. Image A matches B, and B matches C, but A does not quite match C — are they one duplicate set or two? This capability resolves that question the standard way: it treats every image as a node, draws an edge between any two hashes whose Hamming distance is at most the threshold you choose, and reports the connected components as the duplicate groups. You get one entry per group with the member names, every within-threshold pair and its exact distance, and the largest distance inside the group, so you can audit why two images ended up together. Because grouping is by transitivity, a resave chain that drifts one bit at a time still lands in a single cluster, which is exactly how near-duplicate detection is expected to behave on real photo libraries.
Choosing the threshold and the hash format
The threshold is a maximum Hamming distance in bits and defaults to 5, a common starting point for 64-bit hashes: identical images score 0, and visually indistinguishable re-encodes usually stay under 5. Lower it toward 0 when you only want exact or near-exact matches, raise it cautiously when your pipeline resizes aggressively or crops borders, because each extra bit of tolerance multiplies the chance of false positives on large collections. Hashes are accepted in two formats: hexadecimal strings, decoded at four bits per character, or literal 0/1 bitstrings. The only hard rule is consistency — every hash in one request must use the same format and decode to the same bit length, otherwise a distance between them would be meaningless and the request is rejected with a clear error. A negative threshold is likewise rejected as invalid input. You can compute the hashes themselves with the sibling capability image.phash, which produces exactly the 16-character hexadecimal strings this endpoint expects.
Deterministic output you can diff and store
The response is designed to be stored next to your catalog and compared over time. Groups are sorted by the position of their earliest member in your input list, members keep input order, and the internal pairs are listed with both names and the exact distance, so two runs over the same list produce byte-identical output and regressions show up as clean diffs. Alongside the groups you get the counts that dashboards need: total images processed, the bit length of the hashes, the number of duplicate groups, how many images fall inside any group, and how many remain unique. There is no randomness, no clock and no network involved: the same input always yields the same answer, in the API and in the free browser runner on this page, which executes the very same code. That makes it safe to use in CI pipelines that fail a build when new duplicates slip into an asset repository, and in scheduled clean-up jobs that email a deduplication report.
What you can do with it
Deduplicate a product catalog
Feed the pHashes of every product photo and merge listings that reuse the same image under different filenames or sizes.
Audit a stock-photo library
Find near-identical shots from the same session so reviewers keep one representative instead of twelve re-exports.
Guard an upload pipeline
Reject or flag a new upload when its hash lands within the threshold of an image you already store.
FAQ
What does it cost?
$0.002 per request. It is also free to run in your browser on this page with the same code.
Do I upload the images themselves?
No. You send only the perceptual hashes you already computed, plus a name per image. The binary files never leave your side.
Which hash formats are accepted?
Hexadecimal strings (four bits per character, case-insensitive) and literal 0/1 bitstrings. All hashes in one request must share the same format and bit length.
What threshold should I use?
The default is 5 bits, a sensible starting point for 64-bit pHashes. Use 0 for exact matches only and raise it carefully: higher values catch more resaves but also more false positives.
How are groups formed when matches overlap?
By connected components: if A matches B and B matches C within the threshold, all three land in one group even if A and C are farther apart than the threshold. The exact pairwise distances are listed so you can audit each link.
What happens with a negative threshold?
The request is rejected as invalid input, since a Hamming distance cannot be negative, and you are not charged.
For developers — API access
Everything on this page is available programmatically. This section is for teams who want to wire it into their own systems; everyone else can just use the tool above.
API endpoint
Prefer to automate it? One authenticated POST creates the task; the result comes back by webhook or a signed link. The same capability also runs here on the web, by email and from Telegram — and soon from our app too.
Call it from your stack
curl -X POST https://api.kit.forhosting.com/image/duplicate-detect-hash \
-H "Authorization: Bearer $KIT_KEY" \
-H "Content-Type: application/json" \
-d '{"images":[{"name":"hero-a.jpg","hash":"ff8f8383c3c3c3ff"},{"name":"hero-b.jpg","hash":"ff8f8383c3c3c3f7"},{"name":"hero-c.jpg","hash":"ff8f8383c3c3c3f3"},{"name":"logo.png","hash":"007c7c7c7c7c7c00"}]}'const res = await fetch("https://api.kit.forhosting.com/image/duplicate-detect-hash", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.KIT_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
"images": [
{
"name": "hero-a.jpg",
"hash": "ff8f8383c3c3c3ff"
},
{
"name": "hero-b.jpg",
"hash": "ff8f8383c3c3c3f7"
},
{
"name": "hero-c.jpg",
"hash": "ff8f8383c3c3c3f3"
},
{
"name": "logo.png",
"hash": "007c7c7c7c7c7c00"
}
]
})
});
const { task_id } = await res.json();import os, requests
res = requests.post(
"https://api.kit.forhosting.com/image/duplicate-detect-hash",
headers={"Authorization": f"Bearer {os.environ['KIT_KEY']}"},
json={
"images": [
{
"name": "hero-a.jpg",
"hash": "ff8f8383c3c3c3ff"
},
{
"name": "hero-b.jpg",
"hash": "ff8f8383c3c3c3f7"
},
{
"name": "hero-c.jpg",
"hash": "ff8f8383c3c3c3f3"
},
{
"name": "logo.png",
"hash": "007c7c7c7c7c7c00"
}
]
},
)
task_id = res.json()["task_id"]<?php
$res = file_get_contents("https://api.kit.forhosting.com/image/duplicate-detect-hash", false, stream_context_create([
"http" => [
"method" => "POST",
"header" => "Authorization: Bearer " . getenv("KIT_KEY") . "\r\nContent-Type: application/json",
"content" => '{"images":[{"name":"hero-a.jpg","hash":"ff8f8383c3c3c3ff"},{"name":"hero-b.jpg","hash":"ff8f8383c3c3c3f7"},{"name":"hero-c.jpg","hash":"ff8f8383c3c3c3f3"},{"name":"logo.png","hash":"007c7c7c7c7c7c00"}]}',
],
]));
$task = json_decode($res, true);body := bytes.NewBufferString(`{"images":[{"name":"hero-a.jpg","hash":"ff8f8383c3c3c3ff"},{"name":"hero-b.jpg","hash":"ff8f8383c3c3c3f7"},{"name":"hero-c.jpg","hash":"ff8f8383c3c3c3f3"},{"name":"logo.png","hash":"007c7c7c7c7c7c00"}]}`)
req, _ := http.NewRequest("POST", "https://api.kit.forhosting.com/image/duplicate-detect-hash", body)
req.Header.Set("Authorization", "Bearer "+os.Getenv("KIT_KEY"))
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)Example request
{
"images": [
{
"name": "hero-a.jpg",
"hash": "ff8f8383c3c3c3ff"
},
{
"name": "hero-b.jpg",
"hash": "ff8f8383c3c3c3f7"
},
{
"name": "hero-c.jpg",
"hash": "ff8f8383c3c3c3f3"
},
{
"name": "logo.png",
"hash": "007c7c7c7c7c7c00"
}
]
}Example response
{
"task_id": "tsk_a1b2c3d4e5f6a1b2c3d4e5f6",
"type": "image.duplicate_detect_hash",
"status": "queued",
"_links": {
"result": "/tasks/tsk_…/result"
}
}The API is asynchronous: the call returns a task_id immediately and the result arrives by webhook. Polling is capped at 1 req/s per task.
Pricing
Published price — no tokens, no invented credits. A failed task is never charged.
Limits
max_items | 5000 |
Errors
| HTTP | Code | Meaning |
|---|---|---|
401 | unauthorized | Missing or invalid API key. |
402 | insufficient_balance | Your balance doesn't cover the task price. |
404 | unknown_type | That task type doesn't exist. |
429 | rate_limited | Too many requests. Use the webhook instead of polling. |