Quick start
Signing a file takes four calls: upload it, ask for it to be signed, wait for the job, and download the copies.
- Make a key. In your dashboard, open API and make a key. Keep it in an environment variable, here
GHOSTLY_KEY. - Upload a file. Send a 3MF, STL or ZIP as
multipart/form-data. You get an upload id. - Sign it. Name the places you sell it. Each place gets its own copy, so a check can say where a file came from.
- Wait, then download. Signing takes a few minutes. Poll the job, or add a webhook and Ghostly calls you when it's done.
# 1. Upload a file (3MF, STL or ZIP)
curl https://api.signedbyghostly.com/v1/uploads \
-H "Authorization: Bearer $GHOSTLY_KEY" \
-F "file=@geode-planter.3mf"
# -> {"id": "U-5f0c2a9e41b7d3c8", "kind": "3mf", "models": [...], ...}
# 2. Sign it: one copy for each place you sell it
curl https://api.signedbyghostly.com/v1/sign \
-H "Authorization: Bearer $GHOSTLY_KEY" \
-H "Content-Type: application/json" \
-d '{"channels": ["Etsy", "My shop"],
"designs": [{"name": "Geode planter", "parts": [{"upload": "U-5f0c2a9e41b7d3c8"}]}]}'
# -> {"designs": [{"id": "GH-7K3M-Q9TD", "name": "Geode planter", "job": "J-81d2e07c4a9b3f16"}]}
# 3. Wait until the job is done (or let a webhook tell you)
curl https://api.signedbyghostly.com/v1/jobs/J-81d2e07c4a9b3f16 \
-H "Authorization: Bearer $GHOSTLY_KEY"
# -> {"status": "done", ...}
# 4. Download each copy
curl https://api.signedbyghostly.com/v1/designs/GH-7K3M-Q9TD/download/etsy \
-H "Authorization: Bearer $GHOSTLY_KEY" -OJ// Node 18 or newer: fetch, FormData and Blob are built in.
import { readFile, writeFile } from "node:fs/promises";
const API = "https://api.signedbyghostly.com";
const auth = { Authorization: `Bearer ${process.env.GHOSTLY_KEY}` };
async function call(method, path, body) {
const json = body && !(body instanceof FormData);
const r = await fetch(API + path, {
method,
headers: json ? { ...auth, "Content-Type": "application/json" } : auth,
body: json ? JSON.stringify(body) : body,
});
if (!r.ok) throw new Error((await r.json()).error);
return r.status === 204 ? null : r.json();
}
// 1. Upload
const form = new FormData();
form.append("file", new Blob([await readFile("geode-planter.3mf")]), "geode-planter.3mf");
const upload = await call("POST", "/v1/uploads", form);
// 2. Sign: one copy for each place you sell it
const { designs } = await call("POST", "/v1/sign", {
channels: ["Etsy", "My shop"],
designs: [{ name: "Geode planter", parts: [{ upload: upload.id }] }],
});
// 3. Wait for the job
let job;
do {
await new Promise((r) => setTimeout(r, 10000));
job = await call("GET", `/v1/jobs/${designs[0].job}`);
} while (job.status === "queued" || job.status === "running");
if (job.status === "failed") throw new Error(job.error);
// 4. Download each copy
const design = await call("GET", `/v1/designs/${designs[0].id}`);
for (const copy of design.copies) {
const r = await fetch(API + copy.download, { headers: auth });
await writeFile(copy.filename, Buffer.from(await r.arrayBuffer()));
}# Python 3 with requests (pip install requests).
import os
import time
import requests
API = "https://api.signedbyghostly.com"
s = requests.Session()
s.headers["Authorization"] = "Bearer " + os.environ["GHOSTLY_KEY"]
def call(method, path, **kw):
r = s.request(method, API + path, timeout=120, **kw)
if not r.ok:
raise RuntimeError(r.json()["error"])
return r.json() if r.content else None
# 1. Upload
with open("geode-planter.3mf", "rb") as fh:
upload = call("POST", "/v1/uploads", files={"file": fh})
# 2. Sign: one copy for each place you sell it
designs = call("POST", "/v1/sign", json={
"channels": ["Etsy", "My shop"],
"designs": [{"name": "Geode planter", "parts": [{"upload": upload["id"]}]}],
})["designs"]
# 3. Wait for the job
job = {"status": "queued"}
while job["status"] in ("queued", "running"):
time.sleep(10)
job = call("GET", f"/v1/jobs/{designs[0]['job']}")
if job["status"] == "failed":
raise RuntimeError(job["error"])
# 4. Download each copy
design = call("GET", f"/v1/designs/{designs[0]['id']}")
for copy in design["copies"]:
with open(copy["filename"], "wb") as fh:
fh.write(s.get(API + copy["download"], timeout=300).content)API keys
Send your key on every request:
Authorization: Bearer ghostly_live_...- A key acts as your account. It can do everything the dashboard can, except make or revoke keys and delete the account.
- A key is shown once, when you make it. Ghostly stores only a one-way hash of it, so a lost key can't be shown again: revoke it and make a new one.
- Keep keys on your server. Browsers on other sites can't call the API, so a key placed in a web page wouldn't work, and anyone could read it.
- Make one key per system that uses the API. The dashboard shows each key's requests, and revoking one doesn't stop the others.
Plans and limits
Plans are priced by how many designs you keep protected, and the API counts against the same plan as the dashboard. API keys and webhooks come with plans of 500 designs and up. Plans and prices.
| Designs | Your plan's size is how many designs you can keep protected. A design is one model you sell, with all its parts, sizes and versions, up to 25 model files and 300 MB; it counts once for every 10 model files. A design that fails or is cancelled before it starts doesn't count, and a deleted one stops counting (and stops being found by checks). |
|---|---|
| New designs | Up to your plan's size in new designs a month. A month is billed on the most designs you held during it. |
| Checks | Unlimited on paid plans, within the hourly limit below. The free plan includes 10 a month. |
| Free | Uploads, downloads, certificates, lookups, usage and everything else. |
| Requests | 120 a minute per key. Every answer to a request made with a key says where you stand: X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds). Over the limit you get 429 with Retry-After. |
| Per account | 300 uploads a day, 100 sign requests a day (each up to 100 designs), 60 checks an hour, and 50 uploads waiting to be signed at a time. |
| Files | Up to 150 MB per upload. Uploads not signed within 24 hours are deleted. |
Errors
Every error is JSON with a sentence you can show to a person and a word your code can check:
{"error": "This would bring you to 252 designs, and your plan protects up to 250. Move up to 500 designs to sign them.", "code": "limit_reached", "upgrade_to": 500}A 402 also carries upgrade_to: the plan size that would fit, or null above 5,000 designs.
| Status | code | Means |
|---|---|---|
| 400 | bad_request | Something in the request is missing or not valid. The sentence says what. |
| 401 | unauthorized | No key, or a key that's wrong or revoked. |
| 402 | limit_reached | Past your plan's size or this month's new designs, more copies than the plan allows, or out of free checks. |
| 403 | forbidden | Keys can't do this (do it in the dashboard), or your plan doesn't include the API. |
| 404 | not_found | No such thing on your account. |
| 409 | conflict | Not right now: for example, a design that's being signed can't be deleted. |
| 413 | too_large | The file is over 150 MB. |
| 429 | rate_limited | Too many requests. Wait for Retry-After seconds. |
| 503 | unavailable | Something on our side is briefly down. Try again in a minute. |
Every answer carries an X-Request-Id. It's in your dashboard's request log too, and it's what to send us if something looks wrong.
Retrying safely
If a sign request times out, you can't tell whether it went through. Send an Idempotency-Key header with POST /v1/sign, any unique string up to 100 characters, and retry with the same key: for 24 hours Ghostly answers a repeat with the first answer (and Idempotent-Replayed: true) instead of signing and charging twice. The same key with a different body is a 409.
Idempotency-Key: order-1042-geode-planterUploads
Upload each file on its own, then say how the uploads make up designs when you sign.
- 3MF (any slicer's project or a plain 3MF) and STL (binary or ASCII): one model file.
- ZIP: every 3MF and STL inside, at any folder depth, is signed in place, and the folders are kept exactly. Everything else, like PDFs, images and readme files, passes through unchanged. ZIPs inside ZIPs are opened up to 3 levels deep.
POST/v1/uploads
Multipart form with one field, file. Answers 201:
{
"id": "U-5f0c2a9e41b7d3c8",
"name": "dragon-pack.zip",
"kind": "zip",
"bytes": 48211234,
"models": [{"path": "pack/red/red-dragon.3mf", "kind": "3mf", "bytes": 1234567}],
"others": 3,
"folders": [{"name": "pack/blue", "models": 1}, {"name": "pack/red", "models": 2}],
"notes": [],
"created": "2026-11-02T16:00:00Z",
"expires": "2026-11-03T16:00:00Z"
}folders lists the designs a ZIP can be split into, one per folder, when it holds several models side by side in their own folders. notes explains anything that was skipped.
GET/v1/uploads
Your uploads that haven't been signed yet, newest first: {"uploads": [Upload]}.
GET/v1/uploads/{id}
DELETE/v1/uploads/{id}
Answers 204.
Signing
POST/v1/sign
{
"channels": ["Etsy", "My shop"],
"designs": [
{"name": "Red dragon", "parts": [{"upload": "U-aaaa..."}]},
{"name": "Blue dragon", "parts": [{"upload": "U-bbbb...", "folder": "pack/blue"}]},
{"name": "Fox", "parts": [{"upload": "U-cccc..."}, {"upload": "U-dddd..."}]}
]
}channels: the places this batch is sold, one copy each, up to your plan's copies per design. Each name is up to 24 characters.designs: up to 100. A design is a whole upload, one folder of a ZIP upload, or several uploads together (versions or parts of one model). Each upload is used once: whole, or split into its folders.name: up to 80 characters. Left out, it's the file or folder name.- Your account needs a shop name first. It's the owner a check shows.
Answers 202. Every design exists at once, waiting to be signed, and its uploads are used up:
{"designs": [{"id": "GH-7K3M-Q9TD", "name": "Red dragon", "job": "J-81d2e07c4a9b3f16"}]}Each copy comes back as the same files you uploaded, in the same format and folders: a single 3MF or STL comes back as that file, and anything else as a ZIP. Every model carries a visible copyright label with your shop name and the design's ID.
Designs
GET/v1/designs
All your designs, newest first: {"designs": [Design]}.
{
"id": "GH-7K3M-Q9TD",
"name": "Geode planter",
"owner_name": "Your shop",
"registered_at": "2026-11-02T16:01:52Z",
"status": "signed",
"files": 1,
"copies": [{"label": "Etsy", "slug": "etsy", "status": "signed"}],
"sightings": 2,
"last_seen": "2026-11-20T09:12:40Z"
}| status | Means |
|---|---|
queued, working | Waiting, or being signed. The design has a job. |
signed | Every copy carries the full signature and prints exactly like your original. |
partial | Some files or copies carry it. The rest are registered and labelled. |
registered | Registered and labelled. The hidden part was left out because it would have changed how the file prints. |
failed | It couldn't be signed. error says why, and it doesn't count toward your plan. |
sightings counts the times anyone's check found this design. Checks never say who checked.
GET/v1/designs/{id}
One design in full: every file, each copy with its filename, bytes and download path, the certificate path, verify_url (the public page for your listing), and the last 50 sightings with when and which copy.
GET/v1/designs/{id}/download/{copy}
The copy's file, where {copy} is the copy's slug. The file name is in Content-Disposition.
GET/v1/designs/{id}/certificate
The registration certificate, a self-contained HTML file.
DELETE/v1/designs/{id}
A design waiting to be signed is cancelled. A finished one is deleted with its files and registration, for good: files already sold keep their label, but checks stop finding the design. Either way it stops counting toward your plan. This month's new-design count doesn't go down for a finished design you delete.
Checks
POST/v1/checks
Multipart form with one field, file: a 3MF, STL or ZIP up to 150 MB. Answers 202 with {"job": "J-..."}. Checks run ahead of signing and usually take under a minute.
GET/v1/checks
Your last 100 checks, newest first:
{"checks": [{
"job": "J-0d4b1f6e2a9c7385",
"at": "2026-11-02T16:20:00Z",
"file": "found-online.stl",
"status": "done",
"verdict": "signed",
"message": "Signed by Ghostly. Made by Your shop. This is the Etsy copy.",
"design": {"id": "GH-7K3M-Q9TD", "name": "Geode planter", "owner_name": "Your shop", "registered_at": "2026-11-02T16:01:52Z"},
"copy": "Etsy",
"yours": true
}]}verdict is signed (the owner and the exact copy), registered (it matches a registered design but carries no signature) or none (no registered design matches).
Jobs
GET/v1/jobs/{id}
{
"id": "J-81d2e07c4a9b3f16",
"type": "sign",
"status": "running",
"step": "Signing the Etsy copy",
"progress": 0.42,
"eta": 95,
"design": {"id": "GH-7K3M-Q9TD", "name": "Geode planter"}
}status:queued,running,doneorfailed(witherror).ahead(while queued): jobs before this one.eta: seconds until it's done, when there's enough history to say.- When
done,resultholds the design (sign jobs) or the check (check jobs). - Polling every 10 seconds is plenty. A webhook saves you polling at all.
GET/v1/jobs?active=1
Your jobs that are queued or running.
Account and usage
GET/v1/me
Your shop name, places, plan, its limits, what you've used this month and your totals. usage.designs is what you hold now, signings the new designs this month, peak the most you held this month. A null limit has no monthly cap.
{
"shop_name": "Your shop",
"channels": ["Etsy", "My shop"],
"plan": {"size": 500, "name": "500 designs", "status": "active", "price": 3999, "free": false, "custom": false},
"limits": {"designs": 500, "signings": 500, "copies": 10, "checks": null, "api": true},
"usage": {"designs": 312, "signings": 24, "checks": 31, "peak": 318,
"period_start": "2026-11-01T00:00:00Z", "period_end": "2026-12-01T00:00:00Z"},
"totals": {"designs": 58, "copies": 116, "sightings": 9, "checks": 140}
}PATCH/v1/me
Change shop_name (1 to 60 characters) or channels (your usual places, up to 10). Answers like GET /v1/me.
GET/v1/plans
No key needed: every plan size and its monthly price in cents, as the pricing page shows them.
GET/v1/usage?days=30
Day by day, for up to 90 days: requests made with your keys, errors, and designs and checks counted, all of them and the ones made through the API. Also each key's requests and when it was last used.
{
"from": "2026-10-04", "to": "2026-11-02",
"days": [{"date": "2026-11-02", "requests": 214, "errors": 3,
"designs": 6, "designs_api": 5, "checks": 2, "checks_api": 2}],
"totals": {"requests": 4120, "errors": 41, "designs": 38, "designs_api": 33, "checks": 20, "checks_api": 12},
"keys": [{"id": "K-3e1f09a2b7c4", "name": "Shop sync", "hint": "ghostly_live_...x7Qa",
"created": "2026-11-01T18:00:00Z", "last_used": "2026-11-02T16:20:04Z",
"revoked": null, "requests": 4120, "errors": 41}]
}GET/v1/requests?limit=50
The request log: every request made with your keys in the last 30 days, newest first, with the time, key, method, path, status, duration and IP address. Add key=K-... for one key, and before= an at time to page back.
GET/v1/activity?limit=50
What happened on your account, newest first, in sentences: designs signed or failed, checks, a check that found one of your designs, keys and webhooks added or removed. Anything a key started says which key.
Webhooks
Add an HTTPS endpoint in your dashboard or with the API, and Ghostly sends it a POST when something happens. Answer with any 2xx within 10 seconds. A failed delivery is tried again 5 more times over about 9 hours: after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours.
| Event | When | data |
|---|---|---|
design.signed | A design finished signing (signed, partial or registered). | design |
design.failed | A design couldn't be signed. It doesn't count toward your plan. | design, with error |
design.found | A check, anyone's, found one of your designs. | design, kind, copy, by_you |
check.done | A check you started finished. | check |
check.failed | A check you started couldn't finish. | check, with error |
POST /ghostly HTTP/1.1
Content-Type: application/json
Ghostly-Event: design.signed
Ghostly-Delivery: D-4b9e0c71f2a83d65
Ghostly-Signature: t=1793635451,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
"id": "evt_9f2c41d07ab3e512",
"type": "design.signed",
"created": "2026-11-02T16:04:11Z",
"data": {"design": {"id": "GH-7K3M-Q9TD", "name": "Geode planter", "status": "signed", "files": 1,
"copies": [{"label": "Etsy", "slug": "etsy", "status": "signed"}], ...}}
}Check that a call came from Ghostly
Each webhook has its own signing secret, starting whsec_. Ghostly-Signature holds a time t and v1, the HMAC-SHA256 of t, a dot and the raw body, keyed with the secret. Compute the same and compare, and turn away calls more than 5 minutes old.
import crypto from "node:crypto";
// rawBody: the request body exactly as it arrived (before any JSON parsing).
export function fromGhostly(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(parts.v1 || "");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return fresh && a.length === b.length && crypto.timingSafeEqual(a, b);
}import hashlib
import hmac
import time
def from_ghostly(raw_body: bytes, header: str, secret: str) -> bool:
"""raw_body: the request body exactly as it arrived (before any JSON parsing)."""
parts = dict(p.split("=", 1) for p in header.split(","))
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(parts["t"])) < 300
return fresh and hmac.compare_digest(expected, parts.get("v1", ""))A delivery can arrive more than once, so use the event id to skip repeats. To test your endpoint, use Send a test in the dashboard: it sends a ping event.
Managing webhooks with the API
GET/v1/webhooks
Your webhooks (up to 5), each with its last 10 deliveries.
POST/v1/webhooks
{"url": "https://...", "events": ["design.signed", ...]}. Leave out events for all of them. Answers 201 with the webhook and its secret. The URL must be HTTPS on a public address.
GET/v1/webhooks/{id}
One webhook, its secret and its last 50 deliveries.
PATCH/v1/webhooks/{id}
Change url or events, pause it with "enabled": false, or get a new secret with "roll_secret": true.
DELETE/v1/webhooks/{id}
POST/v1/webhooks/{id}/test
Sends a ping now and answers with what happened: {"ok": true, "status": 200, "ms": 84, "error": null}.
Public lookups
GET/v1/public/designs/{id}
No key needed, 120 an hour per IP address. A signed design's name, owner, date and status, or 404. It's what the verify page and listing badge use, at https://signedbyghostly.com/verify/{id}.