# EXIF Metadata Extractor: Photo & Video GPS, Camera, Dates (`bossless/exif-metadata-extractor`) Actor

Read EXIF, XMP, IPTC and video metadata from photo, raw and video links: camera, lens, exposure, capture time with its real UTC offset, GPS with the nearest city, edits, AI hints and a privacy check. Only the metadata bytes are downloaded.

- **URL**: https://apify.com/bossless/exif-metadata-extractor.md
- **Developed by:** [Alexander Daskalov](https://apify.com/bossless) (community)
- **Categories:** Developer tools, Automation, Social media
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.50 / 1,000 file analyzeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## EXIF Metadata Extractor: Photo & Video GPS, Camera, Dates

**Read the hidden metadata of photos, camera raw files and videos from their links.** Paste links (JPG, HEIC iPhone photos, PNG, WebP, DNG and other raw files, MP4 and MOV videos, or Google Drive and Dropbox file links) and get one clean record per file:

- 📍 **GPS position** with altitude, direction, speed and a Google Maps link, plus the **nearest city, region and country** (offline, nothing is sent anywhere).
- 🕒 **When it was really taken**: the local time, its **UTC offset** and the time in UTC. The offset comes from the file itself (the camera's offset tag, the GPS clock, or the time zone at the GPS position on that date), never from a server's clock.
- 📷 **Camera and exposure**: make, model, lens, shutter speed (`1/250`), aperture, ISO, focal length and its 35 mm equivalent, flash, white balance.
- 🎬 **Videos too**: creation time (QuickTime stores it in UTC; iPhone videos keep their local offset), GPS, duration, frame rate, codec and size.
- 🛡️ **A privacy check per file**: does it leak its location, the camera's serial number, the owner's name, an embedded thumbnail or an edit history? `riskLevel` is `high`, `low` or `none`.
- ✏️ **Edits and AI**: the editing software, saves after capture, the IPTC "AI-generated" source type, Stable Diffusion / ComfyUI text in PNGs and C2PA manifests.
- ©️ **Rights**: creator, copyright, title, description and keywords merged from EXIF, IPTC and XMP, with where each came from.
- 🗺️ **Map files for free**: every geotagged file also goes into `photos.geojson`, `photos.kml` (Google Earth, My Maps) and `photos.csv`.

**Only the metadata is downloaded.** A 3.5 MB iPhone photo costs 64 KB of download, and a phone video only its index, not the video, so big files are fast. No API key, no account to connect, no setup.

### What can you do with it?

- **Privacy checks before publishing:** find the photos in a folder, a CMS or a marketplace listing that still carry GPS, serial numbers or owner names.
- **OSINT and verification:** when and where was this photo or video taken, with which device, and was it edited or AI-generated?
- **Photo libraries and DAM:** fill a catalogue with capture dates in UTC, places, cameras, lenses and keywords.
- **Real estate, insurance and field work:** check that inspection or claim photos were taken at the right address and on the right day.
- **Maps of your photos:** open `photos.kml` in Google Earth or `photos.geojson` in any web map.
- **After a scraper:** read the photo links another Actor collected (a dataset field such as `imageUrl`) and add their metadata.

### What files does it accept?

| Input | Example | Supported |
|---|---|---|
| Photos | `https://example.com/photos/IMG_2041.jpg` | ✅ JPG, HEIC/HEIF (iPhone), AVIF, PNG, WebP, TIFF, GIF |
| Camera raw files | `https://example.com/raw/DSC_0412.NEF` | ✅ DNG, CR2, CR3, NEF, ARW, ORF, RW2, RAF and more (read whole) |
| Videos | `https://example.com/clips/IMG_3310.MOV` | ✅ MP4, MOV, M4V, 3GP |
| Google Drive file | `https://drive.google.com/file/d/…/view` | ✅ shared as "Anyone with the link" |
| Dropbox file | `https://www.dropbox.com/scl/fi/…?dl=0` | ✅ |
| Cloud storage file URL | a public S3 or bucket URL | ✅ if it opens without logging in |
| Links in a dataset | another run's dataset ID + the field with the links | ✅ e.g. `imageUrl`, `images[*].url` |
| Web pages, folders, social media posts | | ❌ link the file itself |

Note that most social networks and messengers remove metadata from uploaded photos: link the original files.

### How much does it cost?

Pay per file read. No monthly fee.

| Event | FREE / BRONZE | SILVER | GOLD and up |
|---|---|---|---|
| File analyzed (photo, raw file or video) | **$0.002** | $0.0018 | $0.0015 |
| Actor start | $0.00005 / run | | |

Examples: one photo costs **$0.002**; a privacy check of 50 listing photos costs **$0.10**; 1,000 photos cost **$2.00** ($1.80 on Silver, $1.50 on Gold and up).

You pay only for files that were read. A link that is private, broken or not a photo or video gives an `error` record and isn't charged. A readable file with no metadata **is** charged: "this file is clean" is the answer a privacy check is for. With **Only files with GPS** on, files without a GPS position aren't returned or charged.

**Cap your spend:** set a **maximum cost per run**; the run stops before the file it can't pay for.

### How to read photo metadata

1. Click **Try for free**. The example input reads a geotagged photo, a geotagged video and a photo with no metadata.
2. Paste your own links into **Photo and video links**, one per line, or give a **dataset ID** and the **field with the links**.
3. Optional: turn on **Only files with GPS** or **SHA-256 of each file**, or turn off the nearest city or the raw tags.
4. Run, then open the **Overview**, **GPS and place** or **Privacy check** view, download JSON, CSV or Excel, or open the map files from the run's output.

#### Input example

```json
{
  "files": [
    "https://example.com/photos/IMG_2041.HEIC",
    "https://example.com/clips/IMG_3310.MOV"
  ],
  "reverseGeocode": true,
  "includeAllTags": true,
  "onlyWithGps": false,
  "hashFiles": false,
  "maxFiles": 1000
}
```

Chained after a scraper run:

```json
{
  "datasetId": "YOUR_DATASET_ID",
  "datasetField": "images[*].url",
  "onlyWithGps": true
}
```

### Output

One record per file. An iPhone 12 mini photo from Wikimedia Commons (`allTags` shortened):

```json
{
 "url": "https://upload.wikimedia.org/wikipedia/commons/e/e1/GPS-0012_San_Diego_Air_%26_Space_Museum.jpg",
 "source": "direct",
 "fileName": "GPS-0012_San_Diego_Air_&_Space_Museum.jpg",
 "fileType": "JPEG",
 "mimeType": "image/jpeg",
 "fileSizeBytes": 3487543,
 "bytesDownloaded": 65536,
 "wholeFileRead": false,
 "sha256": null,
 "camera": {"make": "Apple", "model": "iPhone 12 mini", "serialNumber": null, "lensMake": "Apple",
            "lensModel": "iPhone 12 mini back dual wide camera 4.2mm f/1.6",
            "lensId": "iPhone 12 mini back dual wide camera 4.2mm f/1.6", "ownerName": null, "software": "17.6.1"},
 "exposure": {"exposureTime": "1/40", "exposureTimeSec": 0.025, "fNumber": 1.6, "iso": 640, "focalLengthMm": 4.2,
              "focalLength35mm": 26, "exposureProgram": "Program AE", "exposureBiasEv": 0,
              "meteringMode": "Multi-segment", "flashFired": false, "whiteBalance": "Auto"},
 "capture": {"localDateTime": "2024-09-22T14:43:48", "utcOffset": "-07:00", "dateTime": "2024-09-22T14:43:48-07:00",
             "dateTimeUtc": "2024-09-22T21:43:48Z", "offsetSource": "OffsetTimeOriginal", "timeZone": null,
             "dateSource": "DateTimeOriginal"},
 "gps": {"latitude": 32.726547, "longitude": -117.154633, "altitudeM": 72.1, "directionDeg": 151.26,
         "directionRef": "True", "speedKmh": 0, "gpsDateTimeUtc": "2024-09-22T21:43:47Z", "horizontalErrorM": 6.05,
         "mapsUrl": "https://www.google.com/maps?q=32.726547,-117.154633"},
 "place": {"city": "San Diego", "region": "California", "country": "United States", "countryCode": "US",
           "distanceKm": 1.5},
 "media": {"width": 4032, "height": 3024, "orientation": 1, "orientationText": "Horizontal (normal)",
           "rotationDeg": null, "displayWidth": 4032, "displayHeight": 3024, "megapixels": 12.19,
           "durationSec": null, "frameRate": null, "videoCodec": null},
 "editing": {"software": "17.6.1", "processingSoftware": null, "historyAgents": [], "modifiedAfterCapture": false,
             "editedLikely": false, "editReasons": []},
 "rights": {"creator": [], "keywords": [], "copyright": null, "title": null, "description": null, "headline": null,
            "credit": null, "usageTerms": null, "webStatement": null, "rating": null, "city": null, "region": null,
            "country": null, "sublocation": null, "origins": {"creator": [], "keywords": []}},
 "provenance": {"digitalSourceType": null, "aiGenerated": false, "aiGeneratorHints": [], "c2paManifest": false,
                "c2paClaimGenerator": null, "hasEmbeddedThumbnail": true},
 "privacy": {"leaksLocation": true, "leaks": ["gps", "thumbnail"], "riskLevel": "high"},
 "allTags": {"IFD0:Make": "Apple", "IFD0:Model": "iPhone 12 mini", "GPS:GPSLatitude": 32.7265472222222,
             "ExifIFD:ISO": 640},
 "allTagsTruncated": false,
 "warnings": [],
 "processedAt": "2026-10-16T09:12:40+00:00",
 "error": null
}
```

A **video** (a MOV with GPS): QuickTime's creation time is UTC, so the local time and offset come from the time zone at the GPS position on that date:

```json
{"capture": {"localDateTime": "2026-07-14T18:30:05", "utcOffset": "+03:00", "dateTime": "2026-07-14T18:30:05+03:00",
             "dateTimeUtc": "2026-07-14T15:30:05Z", "offsetSource": "quicktime-utc+gps-position",
             "timeZone": "Europe/Sofia", "dateSource": "CreateDate"},
 "media": {"width": 640, "height": 360, "durationSec": 3, "frameRate": 25, "videoCodec": "avc1"}}
```

An **edited photo** with no GPS but a serial number, the owner's name and a Photoshop history:

```json
{"editing": {"software": "Adobe Photoshop 25.0 (Windows)", "historyAgents": ["Adobe Photoshop 25.0 (Windows)"],
             "modifiedAfterCapture": true, "editedLikely": true,
             "editReasons": ["edited with Adobe Photoshop 25.0 (Windows)", "saved 2026-07-20 10:00, after it was taken"]},
 "privacy": {"leaksLocation": false, "leaks": ["serialNumber", "ownerName", "editHistory"], "riskLevel": "low"}}
```

An **AI image** (a Stable Diffusion PNG with the IPTC source type):

```json
{"provenance": {"digitalSourceType": "trainedAlgorithmicMedia", "aiGenerated": true,
                "aiGeneratorHints": ["Stable Diffusion generation parameters in the PNG text"],
                "c2paManifest": false, "c2paClaimGenerator": null, "hasEmbeddedThumbnail": false}}
```

A link that can't be used gives a record with the reason in `error` (not charged):

```json
{"url": "https://example.com/photos/old.jpg", "error": "file not available (HTTP 404): is the link public?"}
```

**Field notes:**

- `capture.offsetSource` says where the UTC offset came from: `OffsetTimeOriginal` (the camera wrote it), `gps-time` (the camera clock against the GPS clock, to the nearest 15 minutes), `gps-position` (the time zone at the GPS position on that date, summer time included), `quicktime-utc+gps-position` (videos) or `quicktime-utc` (a video without GPS: only the UTC time is known). With none of these, `utcOffset` and `dateTimeUtc` stay `null` rather than guessing.
- `gps` is `null` when the file has no position; a `0, 0` position (what cameras without a fix write) counts as none.
- `place` is the nearest town of 1,000+ people in GeoNames, with `distanceKm` to it; far from any town (at sea) it is still the nearest one, so check the distance.
- `media.displayWidth` / `displayHeight` are the size as shown, after the orientation tag (or a HEIC's rotation) is applied.
- `editing.editedLikely` is a hint, not proof: it is true when an editor wrote the file or it was saved after it was taken.
- `provenance.aiGenerated` is true when the file says so (IPTC `trainedAlgorithmicMedia`, generator text in a PNG, an AI tool as software). Most AI images carry no such marks once they have been re-saved: `false` does not mean a photo is real.
- `allTags` keeps every tag ExifTool found, grouped (`IFD0:Make`, `XMP-dc:Subject`, `QuickTime:CreateDate`); binary blocks are left out and it is cut at 200 KB.

### How accurate is it?

The metadata is read by ExifTool, the reference tool photographers, archives and forensic labs use, which knows thousands of tags and every camera maker's private notes. We tested the mapping on sample files with known answers (JPEG, HEIC, PNG, WebP, DNG, MOV, MP4, an edited photo, an AI PNG, photos with only a GPS clock or only a GPS position) and on a real iPhone photo: every camera, exposure, time, offset, GPS, place, privacy, editing and AI field matched. Reading only the metadata part gives exactly the same tags as reading the whole file, on every sample.

### How fast is it?

Files are downloaded 8 at a time and read by one ExifTool process that stays open. Measured on Apify at the default 256 MB: the example input (a photo, a video and a clean photo) finishes in about 3 seconds including start-up; 30 photos and videos took 3.4 seconds, with ExifTool reading a batch of 20 in 0.3 seconds. The 3.5 MB iPhone photo above took 0.2 seconds to fetch, because only its first 64 KB were downloaded.

### What it doesn't do

- **No street addresses:** the place is the nearest city, region and country, from offline data. Street-level addresses would need a paid geocoding service.
- **No metadata the file doesn't have:** most social networks and messengers strip it on upload, and screenshots never had it. Link the original file.
- **No editing or removing metadata:** it reads, it doesn't write.
- **No proof of authenticity:** `editedLikely` and `aiGenerated` report what the file says about itself; a determined forger can change both.
- **No web pages or folders:** link each file itself.

### Use it with AI agents (MCP)

```
https://mcp.apify.com?tools=bossless/exif-metadata-extractor
```

Ask your assistant "where and when were these photos taken, and do any of them leak my location?", and it runs this actor and answers from the records.

### Use the API from code

```bash
curl -X POST "https://api.apify.com/v2/acts/bossless~exif-metadata-extractor/run-sync-get-dataset-items?token=YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"files": ["https://example.com/photos/IMG_2041.jpg"]}'
```

```python
from apify_client import ApifyClient

client = ApifyClient("YOUR_TOKEN")
run = client.actor("bossless/exif-metadata-extractor").call(run_input={
    "files": ["https://example.com/photos/IMG_2041.HEIC", "https://example.com/clips/IMG_3310.MOV"],
})
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    if item["error"]:
        print(item["url"], "error:", item["error"])
        continue
    gps, place = item["gps"] or {}, item["place"] or {}
    print(item["fileName"], item["capture"]["dateTimeUtc"], gps.get("latitude"), gps.get("longitude"),
          place.get("city"), "privacy:", item["privacy"]["riskLevel"])
```

### FAQ

**Is my location data sent anywhere?** No. Places and time zones come from offline data inside the Actor; no geocoding service is called.

**Why is `utcOffset` null for some photos?** Older cameras store the local time without its offset. When the file has neither an offset tag nor a GPS clock or position, the offset can't be known, and the Actor doesn't guess from a server's clock.

**Does it read RAW files?** Yes, through ExifTool. Raw files are downloaded whole (their metadata can sit anywhere), so they take longer than JPEGs.

**How big can files be?** Photos and videos whose metadata is at a known place (JPEG, HEIC, WebP, MP4, MOV) can be any size when the server supports range requests, as nearly all do. Files downloaded whole (PNG, TIFF, raw, SHA-256, servers without range requests) are limited by the run's disk: about 300 MB at the default 256 MB of memory; give the run more memory for bigger ones.

**What happens with a private or broken link?** You get a record with the reason in `error`, and it isn't charged.

**Is this affiliated with ExifTool, Apple, Google or any camera maker?** No. It is not affiliated with any of them.

### Credits

Metadata is read with ExifTool by Phil Harvey (Perl Artistic License / GPL). Place names come from GeoNames (CC BY 4.0) via the reverse-geocode package, and time zones from timezonefinder (MIT) with the IANA time zone database.

# Actor input Schema

## `files` (type: `array`):

Links to photos (JPG, HEIC, PNG, WebP, AVIF, TIFF), camera raw files (DNG, CR2, CR3, NEF, ARW, ORF, RW2, RAF) or videos (MP4, MOV, M4V, 3GP): direct links, or Google Drive / Dropbox file links shared as 'Anyone with the link'. One link per line. Folders and pages that need a login don't work.

## `datasetId` (type: `string`):

ID of a dataset in your account (for example the output of an Instagram, Google Maps or real-estate scraper run). The links in the field below are read, after the links above.

## `datasetField` (type: `string`):

Which field of each dataset item holds the link: `imageUrl`, `displayUrl`, a nested `media.url`, or `images[*].url` for every item of a list.

## `reverseGeocode` (type: `boolean`):

Add the nearest city, region and country (offline GeoNames data, nothing is sent anywhere). City level, not street addresses.

## `includeAllTags` (type: `boolean`):

Also return every tag ExifTool found (allTags, grouped like `EXIF:Make`), binary data left out. Turn off for smaller records.

## `onlyWithGps` (type: `boolean`):

Return (and pay for) only the files that carry a GPS position. Files without one are skipped and not charged.

## `hashFiles` (type: `boolean`):

Download every file in full and return its SHA-256 (for duplicate checks and evidence). Slower: without it only the metadata bytes are downloaded.

## `maxFiles` (type: `integer`):

At most this many links are read per run (after removing duplicates).

## Actor input object example

```json
{
  "files": [
    "https://api.apify.com/v2/key-value-stores/T5HtoeQYut1ufwkKC/records/exif-gps.jpg",
    "https://api.apify.com/v2/key-value-stores/T5HtoeQYut1ufwkKC/records/exif-gps.mov",
    "https://api.apify.com/v2/key-value-stores/T5HtoeQYut1ufwkKC/records/exif-noexif.jpg"
  ],
  "datasetField": "url",
  "reverseGeocode": true,
  "includeAllTags": true,
  "onlyWithGps": false,
  "hashFiles": false,
  "maxFiles": 1000
}
```

# Actor output Schema

## `results` (type: `string`):

No description

## `geojson` (type: `string`):

No description

## `kml` (type: `string`):

No description

## `csv` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "files": [
        "https://api.apify.com/v2/key-value-stores/T5HtoeQYut1ufwkKC/records/exif-gps.jpg",
        "https://api.apify.com/v2/key-value-stores/T5HtoeQYut1ufwkKC/records/exif-gps.mov",
        "https://api.apify.com/v2/key-value-stores/T5HtoeQYut1ufwkKC/records/exif-noexif.jpg"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("bossless/exif-metadata-extractor").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "files": [
        "https://api.apify.com/v2/key-value-stores/T5HtoeQYut1ufwkKC/records/exif-gps.jpg",
        "https://api.apify.com/v2/key-value-stores/T5HtoeQYut1ufwkKC/records/exif-gps.mov",
        "https://api.apify.com/v2/key-value-stores/T5HtoeQYut1ufwkKC/records/exif-noexif.jpg",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("bossless/exif-metadata-extractor").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "files": [
    "https://api.apify.com/v2/key-value-stores/T5HtoeQYut1ufwkKC/records/exif-gps.jpg",
    "https://api.apify.com/v2/key-value-stores/T5HtoeQYut1ufwkKC/records/exif-gps.mov",
    "https://api.apify.com/v2/key-value-stores/T5HtoeQYut1ufwkKC/records/exif-noexif.jpg"
  ]
}' |
apify call bossless/exif-metadata-extractor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,bossless/exif-metadata-extractor"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/XWxD2gFy1sCFi0jaO/builds/A3QxUqWhV8T7dLgke/openapi.json
