MetaMarshal
Log inSign up free
Zero-retention API

The metadata API that processes and forgets.

Route media through /metadata and /scrub to get privacy-friendly files and structured metadata back at scale. Powered by ExifTool — 50+ formats including RAW and video.

◇ Process in memory◇ Delete on 1h TTL◇ Never log content
curl -X POST https://metamarshal.com/api/v1/scrub \
  -H "Authorization: Bearer $METAMARSHAL_KEY" \
  -F "file=@kyoto_2023.heic" \
  -F "profile=strip_all" \
  -F "include_metadata=true"
Endpoints
POST/v1/metadatasyncSend media, get structured JSON back. Read-only — no file returned.
POST/v1/scrubsyncSend media, get the cleaned file back. include_metadata=true also returns what was removed.
POST/v1/batchasyncMany files at once. Returns a job_id immediately; poll or receive a webhook.
GET/v1/jobs/{id}asyncPoll a batch job’s status and its manifest of short-lived signed download URLs.
Base URL: https://metamarshal.com/api · auth via Authorization: Bearer sk_live_…(local dev: http://localhost:3000/api)
Limits & billing units
Size matters — not just request count.
25 MB = 1 credit
Plans are metered in credits: 25 MB = 1 credit, so a 100 MB file is 4 credits — a huge file never costs the same as a snapshot.
50 MB sync cap
/metadata and /scrub take files up to 50 MB. Anything larger — or in bulk — routes to /batch.
2 GB ceiling
The hard per-file limit. Files over it return 413 Payload Too Large before any processing.
Video too
Read metadata from video files today, billed by size like any file. A future update will meter long clips per minute of duration.
Quickstart
Send a file, get a clean copy plus the metadata you removed — in one call.
curl -X POST https://metamarshal.com/api/v1/scrub \
  -H "Authorization: Bearer $METAMARSHAL_KEY" \
  -F "file=@kyoto_2023.heic" \
  -F "profile=strip_all" \
  -F "include_metadata=true"
The moat
One normalized schema.

Every incumbent passes through ExifTool’s raw, format-specific field soup. MetaMarshal returns a stable, versioned schema with consistent grouped names — location, capture, device, hidden, file, sensitive — that stay identical no matter the input format.

JPEGHEICSony RAW→ same shape
POST /v1/metadata · 200
{
  "schema_version": "2026-07.1",
  "file": {
    "name": "kyoto_2023.heic",
    "format": "HEIC",
    "bytes": 3981204,
    "dimensions": [4032, 3024],
    "color_profile": "Display P3",
    "sha256": "9f2c…a1b8"
  },
  "capture": {
    "timestamp": "2023-11-14T15:42:07+09:00",
    "camera": "Apple iPhone 15 Pro",
    "lens": "24 mm ƒ/1.78",
    "settings": { "aperture": 1.78, "shutter": "1/2049", "iso": 64 }
  },
  "location": {
    "lat": 35.0116, "lng": 135.7681,
    "altitude_m": 233, "heading": "NE"
  },
  "device": {
    "make": "Apple", "model": "iPhone 15 Pro",
    "software": "iOS 17.1.1", "serial": "F2L…KQ"
  },
  "hidden": {
    "content_credentials": true,
    "maker_notes": true,
    "comments": ["Shot on iPhone"],
    "keywords": ["kyoto", "autumn"]
  },
  "sensitive": ["location", "device_serial", "embedded_thumbnail",
    "owner_name", "content_credentials", "maker_notes"]
}
GET /v1/jobs/{id} · 200
{
  "job_id": "job_8f3a…",
  "status": "complete",
  "files": [
    {
      "input": "IMG_001.jpg",
      "scrubbed_url": "http://…/api/v1/files/job_8f3a…/0?exp=…&sig=…",
      "removed_metadata": {
        "location": { "lat": 35.01, "lng": 135.76 },
        "device": "iPhone 15 Pro",
        "capture": { "timestamp": "2023-11-14T15:42:07+09:00" }
      }
    }
  ]
}
Batch & scale
Dump a lot of images.

Post many files to /v1/batch and get a job_id back instantly. Poll the job or receive a webhook when it completes.

Results come back as a manifest of short-lived signed URLs — one scrubbed file each, plus its metadata. When the link expires, the file is gone. Zero-retention, enforced by the clock.

Plans
Consumers use the app free. Developers pay to scale.

Every plan includes the full zero-retention API — plans differ only by the monthly credit allowance.

Free200 credits / moEverything you need to start building.
Pro2,000 credits / moFor creators, journalists & sellers who clean photos every day.
Studio25,000 credits / moStrip metadata at scale in your product — zero-retention, compliant.
25 MB = 1 credit · See full pricing & billing → · Get an API key
Authentication
Create a key
Sign in and generate an API key from /dashboard. The full value is shown once — store it in your own secrets manager.
Bearer token
Send it as Authorization: Bearer sk_… on every request. Missing or invalid keys get a 401 authentication_error.
Test vs. live
Keys are prefixed sk_test_ or sk_live_ so environments are obvious at a glance — both work against the same endpoints.
Errors
400invalid_request_errorMalformed request — missing file field, wrong content type, or an invalid batch (no files, or over 500).
401authentication_errorMissing or invalid Authorization: Bearer <key> header.
403permission_errorA signed file URL's HMAC signature is invalid or has expired.
404not_found_errorUnknown or expired batch job — or one that isn't yours. We never confirm another account's job exists.
413request_too_largeFile exceeds the 50 MB sync cap (route it through /batch instead) or the 2 GB hard ceiling.
429rate_limit_errorToo many requests in the sliding window. Retry-After (seconds) is set on the response.
429quota_exceededMonthly credit quota reached for the account. Upgrade at /pricing.
500api_errorProcessing failed unexpectedly for this file — safe to retry.
Rate limits & retries
Per API key, sliding window.
60 req / min
Per-key sliding window on POST /metadata and POST /scrub.
10 req / min
Per-key sliding window on POST /batch — each call can still carry up to 500 files.
429 + Retry-After
Rate limits and quota errors both return 429; rate-limit responses set a Retry-After header in seconds — honor it before retrying.
Backoff, idempotent polling
Back off exponentially on 429/5xx. GET /v1/jobs/{id} is a read with no side effects — poll it as often as you like until status is complete.
Webhooks
Set a batch webhook in /dashboard and MetaMarshal POSTs a batch.completed event when a job finishes — no polling required. Every delivery is HMAC-signed so you can verify it actually came from us.
Headers
X-MetaMarshal-Eventbatch.completed
X-MetaMarshal-Signaturesha256=<hex hmac-sha256 of the raw body>
User-AgentMetaMarshal-Webhooks/1.0
POST your webhook URL
{
  "type": "batch.completed",
  "job_id": "job_8f3a…",
  "status": "complete",
  "file_count": 3,
  "created": "2026-07-03T09:12:04.000Z",
  "completed": "2026-07-03T09:12:41.000Z",
  "files": [
    { "input": "IMG_001.jpg", "scrubbed_url": "https://…/api/v1/files/job_8f3a…/0?exp=…&sig=…" }
  ]
}
import crypto from "node:crypto";

function isValidSignature(rawBody, header, secret) {
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  return !!header && header.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}

// rawBody must be the exact, unparsed request body — not a re-serialized JSON.parse() copy
const ok = isValidSignature(rawBody, req.headers["x-metamarshal-signature"], WEBHOOK_SECRET);
Verify against the raw request body before parsing it as JSON — a re-serialized copy won't match the signature. Always compare with a constant-time function.
Data retention
Process in memory
Uploads are read into memory and processed in the request lifecycle — never written to disk outside the pipeline.
1-hour TTL
Batch outputs are deleted automatically once the job's TTL elapses (1h by default). Signed URLs expire on the same clock — after that the link 403s and the bytes are already gone.
Signed, not public
Every scrubbed_url is HMAC-signed with the expiry baked into the signature — there's no way to list or guess another job's files.
Minimal logging
Request logs record only the endpoint, a size bucket, latency, and status code — never file content or extracted metadata.
MetaMarshal API — process and forget.api.metamarshal.com · runs on GCP