Quickstart
This walk-through uses a test key. Test mode runs every validation, returns real (sample) output files on a realistic timeline, fires real signed webhooks and never charges you. Nothing here needs a balance.
-
Create a test key
Section titled “Create a test key”In the dashboard at api.moan.hot/dashboard, open API keys and create a key in Test mode with the
generations:write,generations:readandaccount:readscopes. The key starts withmh_test_and is shown once. Store it as an environment variable:Terminal window export MOANHOT_API_KEY="mh_test_..."Check it works:
Terminal window curl https://api.moan.hot/v1/account \-H "Authorization: Bearer $MOANHOT_API_KEY"The response has
"mode": "test"and your limits. -
Pick an effect and a variant
Section titled “Pick an effect and a variant”Every effect lists the variants it supports, with a fixed price for each. Take the first effect and its first variant:
Terminal window curl "https://api.moan.hot/v1/effects?type=video&limit=1" \-H "Authorization: Bearer $MOANHOT_API_KEY"Note the
slugand one entry fromvariants, for example:{ "variant_key": "5s_640p", "duration": "5s", "resolution": "640p", "audio": false, "prep": false, "price_micros": 100000, "typical_seconds": 95 }const API = 'https://api.moan.hot/v1';const headers = { Authorization: `Bearer ${process.env.MOANHOT_API_KEY}` };const res = await fetch(`${API}/effects?type=video&limit=1`, { headers });const { data } = await res.json();const effect = data[0];const { duration, resolution, audio, prep } = effect.variants[0];console.log(effect.slug, { duration, resolution, audio, prep });import os, requestsAPI = "https://api.moan.hot/v1"HEADERS = {"Authorization": f"Bearer {os.environ['MOANHOT_API_KEY']}"}effects = requests.get(f"{API}/effects", params={"type": "video", "limit": 1}, headers=HEADERS).json()effect = effects["data"][0]v = effect["variants"][0]variant = {k: v[k] for k in ("duration", "resolution", "audio", "prep") if k in v}print(effect["slug"], variant) -
Upload an input image
Section titled “Upload an input image”POST /filestakes one JPEG, PNG or WebP image up to 20 MB and keeps it 24 hours. You can skip this step and pass a publichttps://image URL in the next one instead.Terminal window curl https://api.moan.hot/v1/files \-H "Authorization: Bearer $MOANHOT_API_KEY" \-F "file=@photo.jpg"The response contains
"id": "file_...".import { readFile } from 'node:fs/promises';const form = new FormData();form.append('file', new Blob([await readFile('photo.jpg')], { type: 'image/jpeg' }), 'photo.jpg');const upload = await fetch(`${API}/files`, { method: 'POST', headers, body: form });const file = await upload.json();console.log(file.id); // file_...with open("photo.jpg", "rb") as f:file = requests.post(f"{API}/files", headers=HEADERS, files={"file": ("photo.jpg", f, "image/jpeg")}).json()print(file["id"]) # file_... -
Create the generation
Section titled “Create the generation”Send the effect slug, the variant, the input and a fresh
Idempotency-Key. The key makes the call safe to retry: if the network drops, send the same request with the same key and you get the same generation back instead of a second one.Terminal window curl https://api.moan.hot/v1/generations \-H "Authorization: Bearer $MOANHOT_API_KEY" \-H "Content-Type: application/json" \-H "Idempotency-Key: $(uuidgen)" \-d '{"effect": "EFFECT_SLUG","variant": { "duration": "5s", "resolution": "640p", "audio": false, "prep": false },"inputs": { "image": "file_..." },"client_reference_id": "order-1042","webhook_url": "https://example.com/webhooks/moanhot"}'const create = await fetch(`${API}/generations`, {method: 'POST',headers: { ...headers, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() },body: JSON.stringify({effect: effect.slug,variant: { duration, resolution, audio, prep },inputs: { image: file.id },client_reference_id: 'order-1042',webhook_url: 'https://example.com/webhooks/moanhot',}),});const generation = await create.json();console.log(create.status, generation.id, generation.status); // 202 gen_... queuedimport uuidgeneration = requests.post(f"{API}/generations",headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())},json={"effect": effect["slug"],"variant": variant,"inputs": {"image": file["id"]},"client_reference_id": "order-1042","webhook_url": "https://example.com/webhooks/moanhot",},).json()print(generation["id"], generation["status"]) # gen_... queuedThe answer is
202 Acceptedwith the generation inqueued. Everyinputsslot the effect lists ininputs[]withrequired: truemust be present; most video effects take one slot calledimage. -
Receive the webhook
Section titled “Receive the webhook”When the output is ready we
POSTa signedgeneration.succeededevent to yourwebhook_url(and to any endpoint registered in the dashboard). A minimal receiver that verifies the signature:// npm install expressimport express from 'express';import crypto from 'node:crypto';const secret = Buffer.from(process.env.MOANHOT_WEBHOOK_SECRET.replace(/^whsec_/, ''), 'base64');const app = express();app.post('/webhooks/moanhot', express.raw({ type: 'application/json' }), (req, res) => {const id = req.header('webhook-id');const ts = req.header('webhook-timestamp');const sigs = (req.header('webhook-signature') ?? '').split(' ');const expected = crypto.createHmac('sha256', secret).update(`${id}.${ts}.${req.body}`).digest('base64');const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;const valid = sigs.some((s) => {const sig = s.split(',')[1] ?? '';return sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));});if (!fresh || !valid) return res.sendStatus(400);const event = JSON.parse(req.body);if (event.type === 'generation.succeeded') {console.log(event.data.id, event.data.outputs[0].url);}res.sendStatus(200);});app.listen(3000);# pip install flaskimport base64, hashlib, hmac, json, os, timefrom flask import Flask, requestSECRET = base64.b64decode(os.environ["MOANHOT_WEBHOOK_SECRET"].removeprefix("whsec_"))app = Flask(__name__)@app.post("/webhooks/moanhot")def moanhot_webhook():body = request.get_data()msg_id = request.headers["webhook-id"]ts = request.headers["webhook-timestamp"]expected = base64.b64encode(hmac.new(SECRET, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()).decode()sigs = [s.split(",", 1)[-1] for s in request.headers.get("webhook-signature", "").split()]if abs(time.time() - int(ts)) > 300 or not any(hmac.compare_digest(s, expected) for s in sigs):return "", 400event = json.loads(body)if event["type"] == "generation.succeeded":print(event["data"]["id"], event["data"]["outputs"][0]["url"])return "", 200The signing secret (
whsec_...) for per-requestwebhook_urldeliveries is on the dashboard’s Webhooks page, one per mode. To test locally, expose the port with a tunnel such ascloudflared tunnel --url http://localhost:3000and use its https URL. -
Or poll
Section titled “Or poll”Without a webhook, read the generation until it is terminal. Output URLs are signed, valid for one hour, and re-issued on every read.
Terminal window curl https://api.moan.hot/v1/generations/gen_... \-H "Authorization: Bearer $MOANHOT_API_KEY"Poll no more than every 5 seconds;
typical_secondson the variant tells you how long to expect.
Go live
Section titled “Go live”- Wait for account review to finish. Test keys work while you wait; live creates answer
403 account_pending_reviewuntil then. - Top up your balance with USDT.
- Create a
mh_live_key and swap it in. Nothing else changes: same URLs, same payloads, same webhooks (signed with the live secret).