SignedByGhostly

Ghostly API

Sign and check 3D print files from your own systems. The API does everything the dashboard does, counted against the same plan, over HTTPS and JSON.

Base URL
https://api.signedbyghostly.com
Sign in with
An API key, made in your dashboard
Available on
Plans of 500 designs and up

Quick start

Signing a file takes four calls: upload it, ask for it to be signed, wait for the job, and download the copies.

  1. Make a key. In your dashboard, open API and make a key. Keep it in an environment variable, here GHOSTLY_KEY.
  2. Upload a file. Send a 3MF, STL or ZIP as multipart/form-data. You get an upload id.
  3. Sign it. Name the places you sell it. Each place gets its own copy, so a check can say where a file came from.
  4. Wait, then download. Signing takes a few minutes. Poll the job, or add a webhook and Ghostly calls you when it's done.
curl
# 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

API keys

Send your key on every request:

Header
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.

DesignsYour 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 designsUp to your plan's size in new designs a month. A month is billed on the most designs you held during it.
ChecksUnlimited on paid plans, within the hourly limit below. The free plan includes 10 a month.
FreeUploads, downloads, certificates, lookups, usage and everything else.
Requests120 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 account300 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.
FilesUp 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:

JSON
{"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.

StatuscodeMeans
400bad_requestSomething in the request is missing or not valid. The sentence says what.
401unauthorizedNo key, or a key that's wrong or revoked.
402limit_reachedPast your plan's size or this month's new designs, more copies than the plan allows, or out of free checks.
403forbiddenKeys can't do this (do it in the dashboard), or your plan doesn't include the API.
404not_foundNo such thing on your account.
409conflictNot right now: for example, a design that's being signed can't be deleted.
413too_largeThe file is over 150 MB.
429rate_limitedToo many requests. Wait for Retry-After seconds.
503unavailableSomething 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.

Header
Idempotency-Key: order-1042-geode-planter

Uploads

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:

Upload
{
  "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

Request
{
  "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:

Response
{"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]}.

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"
}
statusMeans
queued, workingWaiting, or being signed. The design has a job.
signedEvery copy carries the full signature and prints exactly like your original.
partialSome files or copies carry it. The rest are registered and labelled.
registeredRegistered and labelled. The hidden part was left out because it would have changed how the file prints.
failedIt 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:

Response
{"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}

Job
{
  "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, done or failed (with error).
  • ahead (while queued): jobs before this one. eta: seconds until it's done, when there's enough history to say.
  • When done, result holds 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.

Response
{
  "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.

Response
{
  "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.

EventWhendata
design.signedA design finished signing (signed, partial or registered).design
design.failedA design couldn't be signed. It doesn't count toward your plan.design, with error
design.foundA check, anyone's, found one of your designs.design, kind, copy, by_you
check.doneA check you started finished.check
check.failedA check you started couldn't finish.check, with error
A delivery
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.

Node
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);
}

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}.

Make your first key.

The API comes with plans of 500 designs and up. Sign up free, then open API in your dashboard.