# Quickstart

Create a test key, run your first generation and receive its webhook. Complete copy-paste examples in curl, JavaScript and Python.

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.

<Steps>

1. ### Create a test key

   In the dashboard at [api.moan.hot/dashboard](https://api.moan.hot/dashboard), open **API keys** and create a key in **Test** mode with the `generations:write`, `generations:read` and `account:read` scopes. The key starts with `mh_test_` and is shown once. Store it as an environment variable:

   ```sh
   export MOANHOT_API_KEY="mh_test_..."
   ```

   Check it works:

   ```sh
   curl https://api.moan.hot/v1/account \
     -H "Authorization: Bearer $MOANHOT_API_KEY"
   ```

   The response has `"mode": "test"` and your limits.

2. ### 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:

   <Tabs syncKey="lang">
   <TabItem label="curl">

   ```sh
   curl "https://api.moan.hot/v1/effects?type=video&limit=1" \
     -H "Authorization: Bearer $MOANHOT_API_KEY"
   ```

   Note the `slug` and one entry from `variants`, for example:

   ```json
   { "variant_key": "5s_640p", "duration": "5s", "resolution": "640p", "audio": false, "prep": false, "price_micros": 100000, "typical_seconds": 95 }
   ```

   </TabItem>
   <TabItem label="JavaScript">

   ```js
   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 });
   ```

   </TabItem>
   <TabItem label="Python">

   ```python
   import os, requests

   API = "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)
   ```

   </TabItem>
   </Tabs>

3. ### Upload an input image

   `POST /files` takes one JPEG, PNG or WebP image up to 20 MB and keeps it 24 hours. You can skip this step and pass a public `https://` image URL in the next one instead.

   <Tabs syncKey="lang">
   <TabItem label="curl">

   ```sh
   curl https://api.moan.hot/v1/files \
     -H "Authorization: Bearer $MOANHOT_API_KEY" \
     -F "file=@photo.jpg"
   ```

   The response contains `"id": "file_..."`.

   </TabItem>
   <TabItem label="JavaScript">

   ```js
   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_...
   ```

   </TabItem>
   <TabItem label="Python">

   ```python
   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_...
   ```

   </TabItem>
   </Tabs>

4. ### 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.

   <Tabs syncKey="lang">
   <TabItem label="curl">

   ```sh
   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"
     }'
   ```

   </TabItem>
   <TabItem label="JavaScript">

   ```js
   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_... queued
   ```

   </TabItem>
   <TabItem label="Python">

   ```python
   import uuid

   generation = 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_... queued
   ```

   </TabItem>
   </Tabs>

   The answer is `202 Accepted` with the generation in `queued`. Every `inputs` slot the effect lists in `inputs[]` with `required: true` must be present; most video effects take one slot called `image`.

5. ### Receive the webhook

   When the output is ready we `POST` a signed `generation.succeeded` event to your `webhook_url` (and to any endpoint registered in the dashboard). A minimal receiver that verifies the signature:

   <Tabs syncKey="lang">
   <TabItem label="JavaScript">

   ```js
   // npm install express
   import 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);
   ```

   </TabItem>
   <TabItem label="Python">

   ```python
   # pip install flask
   import base64, hashlib, hmac, json, os, time
   from flask import Flask, request

   SECRET = 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 "", 400

       event = json.loads(body)
       if event["type"] == "generation.succeeded":
           print(event["data"]["id"], event["data"]["outputs"][0]["url"])
       return "", 200
   ```

   </TabItem>
   </Tabs>

   The signing secret (`whsec_...`) for per-request `webhook_url` deliveries is on the dashboard's **Webhooks** page, one per mode. To test locally, expose the port with a tunnel such as `cloudflared tunnel --url http://localhost:3000` and use its https URL.

6. ### 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.

   ```sh
   curl https://api.moan.hot/v1/generations/gen_... \
     -H "Authorization: Bearer $MOANHOT_API_KEY"
   ```

   Poll no more than every 5 seconds; `typical_seconds` on the variant tells you how long to expect.

</Steps>

## Go live

1. Wait for account review to finish. Test keys work while you wait; live creates answer `403 account_pending_review` until then.
2. [Top up your balance](/docs/pricing/) with USDT.
3. Create a `mh_live_` key and swap it in. Nothing else changes: same URLs, same payloads, same webhooks (signed with the live secret).
