API Key Guide
Overview
The DeepMark Client API embeds a watermark into an audio, video or image file and detects watermarks in files you upload. You authenticate with an API key. Every job runs asynchronously: you upload a file, get a job ID back and download the result when the job is done.
Base URL
https://app.deepmark.meAuthentication header
Send your API key in this header with every request:
X-Client-Api-Key: <your-api-key>The flow
GET /client/modelslists the models your key can use. Pick one and note its_idand watermark length.POST /client/embeduploads a file and the watermark to embed, and returns a job ID.GET /client/inference-jobs/{job_id}/downloadreturns the watermarked file once the job is done.POST /client/detectuploads a file to check. Its result, downloaded the same way, is JSON that says whether the watermark is there and, for multi-bit models, which bits it carries.
Getting an API Key
Sign in to the DeepMark web app, open API Key in the menu and click Generate API Key. No account yet? Contact us.
- The key is shown only once, when you generate or regenerate it.
- Regenerating a key immediately invalidates the previous one.
- Store the key securely and never expose it publicly.
The examples on this page read the key from an environment variable:
export DEEPMARK_API_KEY="paste-your-api-key-here"Prefer ready-to-use files? The Client API examples repository has the Python and curl quickstarts, a callback receiver and sample audio.
Quickstart with curl
These five steps embed a watermark into a WAV file and detect it again. You need a bash or zsh terminal (macOS, Linux, or WSL on Windows), curl, and an audio file named input.wav in the current directory. No WAV at hand? Download our 11-second speech sample, a public-domain LibriVox recording. Run all five steps in the same terminal: later steps use variables that earlier steps set. These commands save a WAV result, so choose a WAV-producing model. For Metis and other formats, use the curl example script or the Python example below; they use the returned content type to select the output extension.
1. Pick a model
curl -sS "https://app.deepmark.me/client/models" \
-H "X-Client-Api-Key: $DEEPMARK_API_KEY" The response lists every model your key can use, on one line (add | python3 -m json.tool to the command to spread it out). Each entry looks like this:
[
{
"_id": "6ab529a18d7eecb6f6d43f87",
"name": "vigil-multibit-32b-a91",
"version": "v1.0",
"type": "audio",
"watermark_type": "multibit",
"is_default": false,
"description": null,
"max_watermark_length": 32,
"min_watermark_length": 32,
"default_watermarks": [],
"audio_mono_only": false,
"modifiable_run_parameters": false
}
] Pick a model whose type is audio, and note its _id, which you pass as model, and its min_watermark_length and max_watermark_length: the watermark can have any number of bits from the first to the second.
2. Submit an embed job
Set MODEL_ID to the _id you picked, and WATERMARK to a string of zeros and ones of an allowed length. The 32 bits below fit a model whose minimum and maximum are both 32, as they are for most models. For a zero-bit model, whose min_watermark_length is 0, set WATERMARK="".
MODEL_ID="paste-the-_id-here"
WATERMARK="11001100110011001100110011001100"
EMBED_JOB_ID=""
curl -sS -X POST "https://app.deepmark.me/client/embed" \
-H "X-Client-Api-Key: $DEEPMARK_API_KEY" \
-F "file=@input.wav;type=audio/wav" \
-F "watermark=$WATERMARK" \
-F "model=$MODEL_ID" \
| tee embed.json && echo &&
EMBED_JOB_ID=$(grep -o '"job_id": *"[^"]*"' embed.json | cut -d'"' -f4) Keep ;type=audio/wav after the file name to declare its content type explicitly. If curl instead sends application/octet-stream, the API infers the type from a recognized file extension such as .wav. For an unrecognized extension, supply the correct type explicitly. The response is printed and saved in embed.json. It contains the job ID, and the last line stores it in EMBED_JOB_ID:
{"job_id":"3f2b8c1e-5d47-4a9b-9c1e-2a7d6f0b4e91","status":"started"}3. Download the watermarked file
Until the job is done, the download endpoint answers 400 with {"detail":"Result not available yet"}. For a few seconds after the job finishes it can also answer 500 with {"detail":"Failed to fetch result from storage"}, while the result is being stored. The function below asks every 5 seconds and prints each answer. It downloads to a .part file and only renames it when the download is complete, keeps asking after a 400, a 5xx or an interrupted download, stops at once on any other error (see Common Errors) and gives up after 30 minutes. With an 11-second clip, embedding took from a few seconds to under a minute in our tests; the first job after a quiet period takes longest, because the model has to start first.
wait_for_result() {
[ -n "$1" ] || { echo "no job ID, see the submit response above"; return 1; }
local deadline=$((SECONDS + 1800)) left code rc
while left=$((deadline - SECONDS)); [ "$left" -gt 0 ]; do
code=$(curl -sS -m "$left" -o "$2.part" -w "%{http_code}" \
"https://app.deepmark.me/client/inference-jobs/$1/download" \
-H "X-Client-Api-Key: $DEEPMARK_API_KEY")
rc=$?
if [ "$rc" = 0 ] && [ "$code" = 200 ]; then
mv "$2.part" "$2" && echo "saved $2" && return 0
fi
[ "$code" = 200 ] && echo "download interrupted, retrying" || echo "$code $(cat "$2.part" 2>/dev/null)"
rm -f "$2.part"
case "$code" in
200 | 400 | 5?? | 000) left=$((deadline - SECONDS)); [ "$left" -le 0 ] || sleep "$((left < 5 ? left : 5))" ;;
*) return 1 ;;
esac
done
echo "no result after 30 minutes"
return 1
}
wait_for_result "$EMBED_JOB_ID" watermarked.wav If it gives up, run wait_for_result "$EMBED_JOB_ID" watermarked.wav again rather than submitting the file again.
4. Submit a detection job
DETECT_JOB_ID=""
curl -sS -X POST "https://app.deepmark.me/client/detect" \
-H "X-Client-Api-Key: $DEEPMARK_API_KEY" \
-F "file=@watermarked.wav;type=audio/wav" \
-F "model=$MODEL_ID" \
| tee detect.json && echo &&
DETECT_JOB_ID=$(grep -o '"job_id": *"[^"]*"' detect.json | cut -d'"' -f4) Detect with the same model you embedded with. As in step 2, the last line stores the new job ID, this time in DETECT_JOB_ID.
5. Download the detection result
wait_for_result "$DETECT_JOB_ID" result.json && cat result.json && echo For the watermarked file, "decision": true, and for a multi-bit model extracted_watermark holds the bits from step 2. Run steps 4 and 5 on the original input.wav and the decision is false. Detection Results explains every field.
Python Example
The same flow as one script. It generates a random watermark of the right length for the model you choose, waits for each job and prints the embedded and extracted bits. Set up a virtual environment first; on Debian and Ubuntu, install the venv module with sudo apt install python3-venv.
python3 -m venv .venv
source .venv/bin/activate
pip install "requests>=2.34.2"
export DEEPMARK_API_KEY="paste-your-api-key-here" Save the script as deepmark_quickstart.py, or download it from the examples repository:
"""Embed a watermark into a file, then detect it, with the DeepMark Client API.
Usage: python deepmark_quickstart.py <file> [model _id]
Without a model _id, it lists the models your key can use for that kind of file.
Needs DEEPMARK_API_KEY in the environment.
"""
import mimetypes
import os
import random
import sys
import time
import requests
BASE_URL = "https://app.deepmark.me"
CONTENT_TYPES = {
".wav": "audio/wav", ".flac": "audio/flac", ".mp3": "audio/mpeg", ".ogg": "audio/ogg",
".mp4": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm",
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
}
NETWORK_ERRORS = (requests.ConnectionError, requests.Timeout, requests.exceptions.ChunkedEncodingError)
if len(sys.argv) < 2:
sys.exit(__doc__)
if not os.environ.get("DEEPMARK_API_KEY"):
sys.exit('Set your API key first: export DEEPMARK_API_KEY="paste-your-api-key-here"')
HEADERS = {"X-Client-Api-Key": os.environ["DEEPMARK_API_KEY"]}
def check(response):
if not response.ok:
raise RuntimeError(f"HTTP {response.status_code}: {response.text}")
return response
def content_type_of(path):
extension = os.path.splitext(path)[1].lower()
return CONTENT_TYPES.get(extension) or mimetypes.guess_type(path)[0] or "application/octet-stream"
def submit(endpoint, path, **fields):
with open(path, "rb") as f:
files = {"file": (os.path.basename(path), f, content_type_of(path))}
response = requests.post(f"{BASE_URL}/client/{endpoint}", headers=HEADERS,
files=files, data=fields, timeout=300)
return check(response).json()["job_id"]
def wait_for_result(job_id, timeout=1800, interval=5):
url = f"{BASE_URL}/client/inference-jobs/{job_id}/download"
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
response = requests.get(url, headers=HEADERS, timeout=300)
except NETWORK_ERRORS as error:
print(f" connection problem ({type(error).__name__}), retrying")
else:
if response.status_code == 200:
return response
# Retry pending results (400) and server/storage errors (5xx).
if response.status_code != 400 and response.status_code < 500:
check(response)
print(f" waiting: HTTP {response.status_code} {response.text[:80]}")
time.sleep(interval)
raise TimeoutError(f"job {job_id}: no result after {timeout} s; poll it again rather than resubmitting")
def main():
path = sys.argv[1]
kind = content_type_of(path).split("/")[0]
models = check(requests.get(f"{BASE_URL}/client/models", headers=HEADERS, timeout=60)).json()
model = next((m for m in models if len(sys.argv) > 2 and m["_id"] == sys.argv[2]), None)
if model is None:
print(f"Your {kind} models (pass one _id as the second argument):")
for m in models:
if m["type"] == kind:
print(f" {m['_id']} {m['name']} {m['version']}, {m['min_watermark_length'] or 0}-bit {m['watermark_type']}")
sys.exit(1)
print(f"model: {model['name']} {model['version']}")
# Use the model's minimum bit length; zero-bit models take an empty watermark.
bits = "".join(random.choice("01") for _ in range(model["min_watermark_length"] or 0))
job_id = submit("embed", path, model=model["_id"], watermark=bits)
print("embed job:", job_id)
watermarked = wait_for_result(job_id)
# Use the response type: an MP3 upload can return WAV.
returned_type = watermarked.headers.get("Content-Type", "").split(";")[0]
extension = next((e for e, t in CONTENT_TYPES.items() if t == returned_type), os.path.splitext(path)[1])
output_path = "watermarked" + extension
if os.path.exists(output_path) and os.path.samefile(output_path, path):
output_path = "watermarked_again" + extension
if os.path.exists(output_path) and os.path.samefile(output_path, path):
sys.exit("Output aliases the input; run from another directory")
with open(output_path, "wb") as f:
f.write(watermarked.content)
print("saved", output_path)
job_id = submit("detect", output_path, model=model["_id"])
print("detect job:", job_id)
result = wait_for_result(job_id).json()
if result.get("success") is False:
sys.exit(f"detection failed: {result.get('error_message') or 'no reason given'}")
print("watermark found:", result["decision"])
if result.get("extracted_watermark"):
print("embedded: ", bits)
print("extracted:", "".join(map(str, result["extracted_watermark"])))
try:
main()
except (OSError, RuntimeError, ValueError, requests.RequestException) as error:
sys.exit(str(error)) Run it with a file. Without a model _id it lists the models your key can use for that kind of file; run it again with one of them:
python deepmark_quickstart.py input.wav
python deepmark_quickstart.py input.wav paste-the-_id-hereExample output:
model: vigil-multibit-32b-a91 v1.0
embed job: ee262339-5fa5-4686-aa41-40c1c5c98667
waiting: HTTP 400 {"detail":"Result not available yet"}
saved watermarked.wav
detect job: 5dfe5b9f-868f-4853-ac19-6b600b139faf
waiting: HTTP 400 {"detail":"Result not available yet"}
watermark found: True
embedded: 00011000000011110110100101000101
extracted: 00011000000011110110100101000101 The script works the same way with a .png or .jpg file and an image model. It polls for up to 30 minutes for each result; an in-progress request may finish later. It reads the whole file into memory; for very large videos, use the curl commands above.
Models
GET /client/models returns the models available to your API key. GET /client/models?name=<name> returns only the versions of one model.
| Field | Meaning |
|---|---|
_id | The model ID. Pass it as model to embed and detect. |
name,version | Passing the name as model also works, with version to choose a version. Prefer the _id: without a version, a name resolves to that model's default or newest version, which may not be one your key can use, and versions can take different watermark lengths. |
type | audio, video or image: the kind of file the model accepts. |
watermark_type | multibit: the watermark carries bits you choose, and detection reads them back. zerobit: the watermark carries no bits, and detection says whether it is present. |
min_watermark_length,max_watermark_length | The allowed watermark length in bits, from the minimum to the maximum. The two are usually equal, so the watermark must have exactly that many bits; zero-bit models have 0. |
audio_mono_only | If true, the model rejects audio with more than one channel. |
modifiable_run_parameters | If true, embed accepts run_parameters (see Video Watermarking and Image Watermarking). |
is_default | The version used when you pass only the model name. |
Swipe the table sideways to see all columns.
Submit Embed Job
POST /client/embed, sent as multipart/form-data:
| Field | Description |
|---|---|
file | Required. The file to watermark. Its content type must match the model's type: it has to start with audio/, video/ or image/. With curl, set it after the file name: -F "file=@input.wav;type=audio/wav", -F "file=@input.mp3;type=audio/mpeg", -F "file=@input.mp4;type=video/mp4". Audio models read WAV, FLAC, MP3, OGG and AIFF. Other audio formats, such as M4A (AAC), pass this check, but the job fails. |
watermark | Required. A string of 0 and 1, at least min_watermark_length and at most max_watermark_length characters long. For a zero-bit model, send the field empty: -F "watermark=". |
model | Required. The model's _id (recommended), or its name. |
version | Optional. A model version, when model is a name. |
run_parameters | Optional. Quality settings as one JSON object, for models with modifiable_run_parameters: true. |
callback_url | Optional. A URL that receives a POST when the job finishes (see Waiting for Results). |
Swipe the table sideways to see all columns.
The response is {"job_id": "...", "status": "started"}. The result is the watermarked file. Most audio models return WAV. Metis returns FLAC with a .flac download filename; tools that only read WAV need it converted, for example with ffmpeg -i watermarked.flac converted.wav.
Submit Detection Job
POST /client/detect takes file, model and, optionally, version and callback_url, with the same rules as embed. Use the model that embedded the watermark: each model detects only its own watermarks. Video and image models also check that the watermark was embedded from your organisation; a file watermarked from another organisation's account comes back with a false decision. The response is a job ID, and the result is JSON.
Detection Results
decision is the answer: true if the file carries the model's watermark. The rest depends on the kind of model.
Multi-bit audio models
The result of step 5 of the quickstart, shortened to the first two of its eleven detections:
{
"success": true,
"detections": [
{
"start": 0,
"end": 23936,
"confidence": 98.59764575958252,
"watermark": [1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0,
1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0]
},
{
"start": 23936,
"end": 47872,
"confidence": 96.81532979011536,
"watermark": [1, 1, 0, 0, 1, 1, 1, 0, 1, 1, 0, 0, 1, 1, 0, 0,
1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0]
}
],
"decision": true,
"extracted_watermark": [1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0,
1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0],
"error_message": null,
"metadata": { "processing_time": 0.3590836524963379 }
}extracted_watermarkis the watermark read from the whole file, combined over all windows. It isnullwhendecisionisfalse.- Each entry in
detectionscovers about one second of audio.confidenceruns from 0 to 100, andwatermarkholds the bits read from that window alone. A single window can differ from the final result in a bit or two, as the second window above does.startandendare sample positions at the model's own sample rate, not your file's. - On the original, unwatermarked file every window scored below 32 and the decision was
false.
Zero-bit audio models
Zero-bit results have no extracted_watermark, and their windows are frames:
{
"success": true,
"detections": [
{ "frame_idx": 0, "logit": 0.9999765753746033, "confidence": 0.9999814251696457, "is_watermarked": true },
{ "frame_idx": 1, "logit": 0.9999972581863403, "confidence": 0.9999978669131904, "is_watermarked": true }
],
"decision": true,
"error_message": null,
"metadata": {
"processing_time": 13.690149784088135,
"original_sample_rate": 16000,
"model_sample_rate": 44100,
"audio_duration_sec": 11.22,
"mean_logit": 0.9999898672103882,
"num_confident_frames": 11,
"num_positive": 11,
"num_negative": 0,
"total_frames": 11,
"positive_ratio": 1.0,
"negative_ratio": 0.0,
"confident_ratio": 1.0,
"confidence_threshold": 0.75
}
}is_watermarkedis the verdict for one frame, andconfidence(0 to 1) is how sure the model is of that verdict. A clean file also scores close to 1, withis_watermarked: false.metadatasummarizes the frames: how many were positive and negative, and the share of each.
Video and image models
Video and image results have no detections list. A result for a 1920×1080 image (the 256-bit raw_watermark_bits, input_size and the timing fields left out):
{
"status": "completed",
"mode": "detect",
"asset_type": "image",
"decision": true,
"extracted_watermark": [0, 0, 1, 0, 0, 1, 1, 1, 0, 1, 0, 1, 0, 1, 1, 1,
0, 0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 1, 1, 1,
1, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 1,
1, 1, 1, 1, 1, 0, 1, 1, 0, 1, 0, 1, 0, 1, 0, 1],
"confidence": 0.9951171875,
"client_id": "01111010",
"crc_valid": true,
"bch_success": true,
"corrected_errors": 0,
"total_tiles": 4,
"tiles_attempted": 4,
"tiles_errored": 0,
"input_width": 1920,
"input_height": 1080
}decisionistruewhenconfidence(0 to 1) is above the model's threshold and the error-correction checks pass (bch_success,crc_valid).extracted_watermarkholds the 64 bits you embedded. It is an empty list when nothing can be decoded, and it can hold bits while the decision isfalse, so check the decision first.
Download Job Result
GET /client/inference-jobs/{job_id}/download returns the result itself: the watermarked file for an embed job, JSON for a detection job. Only the account that submitted the job can download it.
Presigned URL
Add ?presign=1 to get a temporary link instead, for example to hand the file to a browser or another service without sharing your API key. The answer is {"url":"https://storage.googleapis.com/..."}. This gets a link for the watermarked file from step 3 and downloads it again through the link:
URL=""
curl -sS "https://app.deepmark.me/client/inference-jobs/$EMBED_JOB_ID/download?presign=1" \
-H "X-Client-Api-Key: $DEEPMARK_API_KEY" \
| tee presign.json && echo &&
URL=$(grep -o '"url": *"[^"]*"' presign.json | cut -d'"' -f4) &&
curl -sS -L --fail -o watermarked_from_link.wav.part "$URL" \
&& mv watermarked_from_link.wav.part watermarked_from_link.wav \
|| { rm -f watermarked_from_link.wav.part; false; }- The link works for one hour and needs no API key, so anyone who has it can download the file.
- It downloads as an attachment. Add
&disposition=inlinefor playback in a browser.
Waiting for Results
Polling
Call the download endpoint every few seconds until it returns 200, as the quickstart does. While a job is queued or running, it returns 400 with {"detail": "Result not available yet"}. Right after a job finishes it can answer 500 with {"detail": "Failed to fetch result from storage"} for a few seconds; treat that, and any other 5xx answer, like 400 and keep polling. A failed attempt that will be retried can also return 400. Once a failure or timeout is final, the download answers 409 with Job failed or Job timed out; stop polling that job. Both examples use a 30-minute polling limit. An in-progress Python request may finish later. If a job is only slow, poll it again; submitting the file again starts a new job.
Check job status
To distinguish a slow job from a final failure, request GET /client/inference-jobs/{job_id} with your API key. For the quickstart's embed job:
curl -sS "https://app.deepmark.me/client/inference-jobs/$EMBED_JOB_ID" \
-H "X-Client-Api-Key: $DEEPMARK_API_KEY" The response includes job_id, job_type, status, timestamps and, for a final failure, error_message. A failed attempt that is still eligible for retry reports running. Only failed and timeout are final failures.
Callbacks
To be told when a job finishes, add callback_url to the embed or detection request:
curl -sS -X POST "https://app.deepmark.me/client/embed" \
-H "X-Client-Api-Key: $DEEPMARK_API_KEY" \
-F "file=@input.wav;type=audio/wav" \
-F "watermark=$WATERMARK" \
-F "model=$MODEL_ID" \
-F "callback_url=https://your-server.example.com/deepmark-callback"For a minimal receiving server, see the callback receiver example.
When the job completes, fails or times out, DeepMark sends a POST with a JSON body to that URL. Its job_id and status fields (completed, failed or timeout) tell you which job ended and how; error_message says why a job failed. Build the download URL from the job_id yourself, https://app.deepmark.me/client/inference-jobs/{job_id}/download, and send your API key only there. Callbacks are not signed, so never send your key to a URL taken from the callback body, such as its download_url.
The callback is sent before the result is stored, and storing waits until your server has answered (at most 15 seconds). Answer the callback right away, then download the result; if the download answers 400 or 500, retry for a short while. Each callback is a single attempt with no retry, and it is not signed: treat it as a notification, poll if your server misses it, and confirm what it says by downloading the result. The same job can be reported more than once. A failed attempt can still be retried; check the job status endpoint to see whether the failure is final.
Common Errors
Errors come back as JSON with a detail field. It is usually a message; for invalid run_parameters and missing fields it is an object or a list that names the field. The ones you are most likely to see:
| Status | Message | Fix |
|---|---|---|
| 400 | Model '…' expects audio input, got 'application/octet-stream' | If the extension is unrecognized or misleading, declare the correct content type explicitly. For a WAV file with curl: -F "file=@input.wav;type=audio/wav". |
| 400 | Watermark must be at least 32 bits long / Watermark cannot exceed 32 bits | Send between min_watermark_length and max_watermark_length bits for this model. |
| 400 | Watermark must contain only 0 and 1 | Send the bits as a string such as 0110…. |
| 400 | Model name is required / Unknown model '…' / Unknown model id '…' | Send model with an _id from /client/models. |
| 400 | Model id '…' exists but not with version '…' | Leave out version when you pass an _id. |
| 400 | This model requires mono audio. Your file has 2 channels. … | Convert the file to one channel, for example with ffmpeg -i input.wav -ac 1 mono.wav. |
| 400 | run_parameters must be valid JSON / Invalid run_parameters / Selected model does not support modifiable run parameters | Put the JSON in single quotes in the shell, use only the keys and ranges the model accepts, and only send run_parameters to models that allow it. |
| 400 | Result not available yet | The job is pending, running or retrying, or its result is not stored yet. Keep polling until your timeout; check the job status endpoint if it takes longer than expected. |
| 409 | Job failed / Job timed out | The failure is final. Stop polling this job; check its status and error message before deciding whether to submit a new one. |
| 401 | Missing client API key / Invalid client API key | Send the X-Client-Api-Key header with your current key. A regenerated key replaces the old one. |
| 403 | Model not available for your organisation | Use a model _id from your /client/models list. |
| 403 | Not authorized to download this job | The job was submitted by another account. |
| 404 | Inference job not found | Check the job ID. |
| 422 | Field required | A required form field is missing. A zero-bit model still needs watermark, sent empty. |
| 500 | Failed to fetch result from storage | The job has just finished and its result is still being stored. Try again a few seconds later. |
| 500 | Inference submission failed | A problem on our side. Try again later, and contact us if it persists. |
Swipe the table sideways to see all columns.
Security Best Practices
- Call the API from your backend. Never put the key in browser or mobile app code, or in a public repository.
- Keep the key in a secrets manager or an environment variable.
- Rotate the key regularly. Regenerating it invalidates the old key at once, so update every integration right away.
- Callbacks are not signed: confirm a callback by downloading the result with your key before you act on it, and never send your key to a URL that came in a callback.
- Anyone with a presigned URL can download the file until the URL expires, after one hour.
