# Public Customer API

Dokumentasi ini untuk API customer yang dijual ke pengguna. Detail akun, cookie, balance token, dan generation ID Leonardo tidak pernah dikembalikan oleh endpoint `/v1`.

Dokumentasi interaktif tersedia di `/docs`.

## Navigasi

- [Authentication dan idempotency](#base-url)
- [Generate image](#generate-image)
- [Generate video](#generate-video)
- [Job status](#job-status)
- [Job result](#job-result)
- [Usage](#usage)
- [Limits](#limits)
- [Error contract](#error-contract)

## Base URL

Production:

```text
https://admin.seedance2unlimited.space
```

Local development:

```text
http://localhost:3000
```

Semua endpoint customer memakai:

```http
Authorization: Bearer <ak_live_... atau ak_test_...>
Content-Type: application/json
```

Endpoint generate juga wajib memakai:

```http
Idempotency-Key: <request-unik>
```

## Setup Customer Oleh Operator

Endpoint pada bagian ini memakai private operator key, bukan customer API key.

### Buat Customer

```http
POST /api/public/customers
Authorization: Bearer <MY_PRIVATE_API_KEY>
```

```json
{
  "email": "customer@example.com",
  "name": "Customer Example",
  "planId": "starter",
  "creditsBalance": 10000
}
```

Jika `creditsBalance` tidak dikirim, opening balance mengikuti `monthlyCredits` plan.

### Buat API Key

```http
POST /api/public/customers/:customerId/api-keys
Authorization: Bearer <MY_PRIVATE_API_KEY>
```

```json
{
  "mode": "live",
  "name": "Production"
}
```

Full API key hanya muncul sekali pada response pembuatan. Backend menyimpan hash SHA-256, prefix, dan metadata key, bukan key plaintext.

### Topup Atau Adjust

```http
POST /api/public/customers/:customerId/credits
Authorization: Bearer <MY_PRIVATE_API_KEY>
```

Topup:

```json
{
  "type": "topup",
  "credits": 10000,
  "reason": "payment-order-123"
}
```

Adjustment dapat memakai nilai positif atau negatif. Saldo tidak dapat diturunkan melewati credit yang sedang di-reserve.

```json
{
  "type": "adjust",
  "credits": -100,
  "reason": "manual-correction"
}
```

## Generate Image

```http
POST /v1/images/generate
Authorization: Bearer <customer_api_key>
Idempotency-Key: image-order-123
```

```json
{
  "prompt": "cinematic product photo",
  "quality": "LOW",
  "size": "16:9_SMALL"
}
```

Supported quality:

```text
LOW
MEDIUM
```

Supported size:

```text
16:9_SMALL
1:1_SMALL
2:3_SMALL
9:16_SMALL
```

Public price default:

```text
LOW     = 20 customer credits
MEDIUM  = 120 customer credits
```

Harga customer berada di `src/config/public-api.config.js` dan terpisah dari token Leonardo internal.

Response berhasil menggunakan HTTP 202:

```json
{
  "ok": true,
  "jobId": "job_...",
  "status": "processing",
  "type": "image",
  "model": "gpt-image-2",
  "chargedCredits": 20,
  "createdAt": "ISO",
  "updatedAt": "ISO",
  "idempotentReplay": false
}
```

### Image Reference Dari URL

```json
{
  "prompt": "keep the same character, cinematic lighting",
  "quality": "LOW",
  "size": "1:1_SMALL",
  "referenceImage": {
    "imageUrl": "https://example.com/reference.png",
    "strength": "MID"
  }
}
```

URL public wajib HTTPS, maksimal 20 MB, dan harus mengembalikan content type JPG, PNG, atau WEBP. Localhost, alamat private, embedded credentials, dan redirect menuju jaringan private ditolak.

### Image Reference Dari Desktop

Client membaca file desktop lalu mengirimnya sebagai data URL atau base64:

```json
{
  "prompt": "keep the same product shape",
  "quality": "LOW",
  "size": "1:1_SMALL",
  "referenceImage": {
    "dataUrl": "data:image/png;base64,iVBORw0KGgo...",
    "fileName": "reference.png",
    "mimeType": "image/png",
    "strength": "MID"
  }
}
```

Backend memilih akun Leonardo yang cukup balance sebelum upload. Upload dan generate kemudian dikunci ke akun yang sama karena ID reference image bersifat account-bound.

## Generate Video

```http
POST /v1/videos/generate
Authorization: Bearer <customer_api_key>
Idempotency-Key: video-order-123
```

```json
{
  "prompt": "cinematic drone shot over a rice field",
  "size": "16:9_SMALL",
  "duration": 15,
  "motionHasAudio": true
}
```

Supported size dan harga public default:

```text
16:9_STANDARD  = 3500 customer credits
1:1_STANDARD   = 3500 customer credits
9:16_STANDARD  = 3500 customer credits
16:9_SMALL     = 9000 customer credits
1:1_SMALL      = 9000 customer credits
9:16_SMALL     = 9000 customer credits
```

Seedance 2.0 Fast saat ini hanya menerima `duration: 15`. `referenceImage` memakai format yang sama dengan generate image.

## Job Status

```http
GET /v1/jobs/:jobId
Authorization: Bearer <customer_api_key>
```

Endpoint ini sekaligus menyinkronkan status terbaru dari Leonardo. Customer hanya dapat membaca job miliknya sendiri.

Status public:

```text
queued
processing
complete
failed
```

## Job Result

```http
GET /v1/jobs/:jobId/result
Authorization: Bearer <customer_api_key>
```

Saat belum selesai, endpoint mengembalikan HTTP 202. Image selesai:

```json
{
  "ok": true,
  "jobId": "job_...",
  "status": "complete",
  "type": "image",
  "model": "gpt-image-2",
  "chargedCredits": 20,
  "imageUrl": "https://...",
  "width": 1376,
  "height": 768
}
```

Video selesai:

```json
{
  "ok": true,
  "jobId": "job_...",
  "status": "complete",
  "type": "video",
  "model": "seedance-2.0-fast",
  "chargedCredits": 9000,
  "videoUrl": "https://...",
  "previewUrl": "https://..."
}
```

## Usage

```http
GET /v1/usage
Authorization: Bearer <customer_api_key>
```

```json
{
  "ok": true,
  "customerId": "cus_...",
  "plan": "starter",
  "creditsBalance": 12000,
  "creditsReserved": 9000,
  "creditsAvailable": 3000
}
```

## Credit Lifecycle

```text
request diterima  -> reserve customer credits
Leonardo menerima -> commit customer credits
gagal sebelum itu -> refund reservation
generation FAILED -> refund committed credits
```

Ledger bersifat append-only dengan event `reserve`, `commit`, `refund`, `topup`, dan `adjust`. Event dapat dilihat operator melalui:

```http
GET /api/public/usage-ledger?customerId=:customerId
```

## Idempotency

```text
customer sama + key sama + payload sama     -> job lama dikembalikan
customer sama + key sama + payload berbeda  -> HTTP 409 idempotency_key_conflict
```

Gunakan key baru untuk setiap tindakan generate baru. Retry network harus memakai key yang sama.

## Limits

Plan menentukan:

```text
requestsPerMinute
maxConcurrentImageJobs
maxConcurrentVideoJobs
maxQueuedJobs
```

Response limit menggunakan HTTP 429 dengan code `rate_limit_exceeded`, `concurrency_limit_exceeded`, atau `queue_full`. Header `X-RateLimit-*` dikembalikan pada request customer yang berhasil melewati auth.

## Error Contract

```json
{
  "ok": false,
  "error": {
    "code": "customer_balance_low",
    "message": "Customer credits are insufficient (0/20).",
    "jobId": "job_...",
    "creditsAvailable": 0,
    "requiredCredits": 20
  }
}
```

Public response tidak menampilkan account ID, balance token Leonardo, cookie, bearer, atau daftar akun rotasi.

## Operator Endpoints

Semua endpoint berikut memakai `MY_PRIVATE_API_KEY` untuk automation atau secure admin session dari panel `/admin`:

```text
GET    /api/public/plans
GET    /api/public/customers
POST   /api/public/customers
GET    /api/public/customers/:customerId
PATCH  /api/public/customers/:customerId
POST   /api/public/customers/:customerId/api-keys
GET    /api/public/customers/:customerId/api-keys
DELETE /api/public/api-keys/:keyId
POST   /api/public/customers/:customerId/credits
GET    /api/public/usage-ledger
GET    /api/public/jobs
```

## Current MVP Boundary

- Webhook belum aktif. `webhookUrl` ditolak dengan `webhook_not_enabled`; gunakan polling job.
- JSON storage aman terhadap request paralel di satu proses Node melalui lock per customer dan atomic rename.
- Jangan menjalankan beberapa instance backend terhadap file JSON yang sama. Pindahkan customer, key, ledger, idempotency, dan job ke PostgreSQL sebelum horizontal scaling.
- API key harus dikirim melalui HTTPS pada deployment public.
