{
  "openapi": "3.1.0",
  "info": {
    "title": "PropulSell Public API",
    "version": "1.0",
    "description": "The PropulSell Public API lets Expert Pro customers call PropulSell's AI generation\ntools (Ad Crafter, Beautifier, Recoloration, Photo Shoots, Video, Model Generation)\nprogrammatically, using the same underlying pipelines as the PropulSell dashboard.\n\n## New Studio tools\nEditorial and Detail Repair are available in the Studio at `/studio/editorial` and\n`/studio/detail-repair`. Each image generation costs 10 credits. Their current\ngeneration routes (`POST /api/editorial/generate` and\n`POST /api/detail-repair/generate`) require a signed-in Studio browser session;\nthey do not accept Expert Pro API keys and are not part of the `/api/v1` contract.\nEditorial accepts a prompt, up to five reference image URLs, aspect ratio, quality and tags.\nDetail Repair accepts a caller-owned `source_path`, PNG mask, optional instruction, mode\nand up to five caller-owned reference paths. Both return a generation ID and result URL.\nPublic API-key support for these tools is not available yet.\n\n## Plan requirement\nEvery endpoint requires an API key belonging to a user on the **Expert Pro** plan.\nKeys are created from Studio → Settings → API Keys and are only usable while the\nowning account remains on Expert Pro; if the account is downgraded, existing keys\nstop working (`403 forbidden`) even though the key itself is still active.\n\n## Shared credit pool\nAPI usage draws from the **same credit pool** as the dashboard. There is no\nseparate API credit allowance — generating via the API deducts credits exactly\nlike generating via the dashboard UI, and a failed generation is refunded the\nsame way. Check remaining balance with `GET /credits`.\n\n## Sync vs. async tools\nTools fall into two categories with different response shapes:\n\n- **Sync** (`POST /ad-crafter`, `POST /beautifier`, `POST /recoloration`) run the\n  full generation inline and return the finished result directly with `200 OK`.\n- **Async** (`POST /photoshoots`, `POST /video`, `POST /models`) only validate the\n  request, deduct credits, and enqueue the job, returning `202 Accepted` with a\n  `job_id` immediately. The actual generation continues in the background after\n  the response is sent. Poll `GET /jobs/{id}` until `status` is `completed` or\n  `failed` to retrieve the result.\n\nAll responses (success and error) are wrapped in the same `{ data, error }`\nenvelope — see `components.schemas.Envelope`. Errors carry a machine-readable `code`,\na `message` and a resolution `hint`. Unknown `/api/*` paths also answer with this JSON\nenvelope (`404 not_found`), never an HTML page.\n\n## Rate limits\nEvery API response carries `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`\n(seconds) and `RateLimit-Policy` headers (IETF `ratelimit-headers` draft). The default\nallowance is 60 requests per minute per key. A `429` also carries `Retry-After`.\n\n## Errors\nEvery 4xx/5xx response from `/api/v1` has the typed shape `ErrorEnvelope`\n(`{ \"data\": null, \"error\": { \"code\", \"message\", \"hint\", \"docs_url\" } }`). `code` is one of a\nclosed enum, so clients can branch on it without parsing prose. The blog automation\nendpoints return `BlogError` (`{ \"error\": \"...\" }`) instead.\n\n## Versioning and deprecation\nThe major version is part of the URL (`/api/v1`) and echoed in the `API-Version` response\nheader. Within a major version we only add optional request fields, response fields and endpoints.\nA breaking change ships as a new major version; the old one keeps working during a sunset window.\nA deprecated endpoint answers with a `Deprecation` header (RFC 9745) and a `Sunset` header\n(RFC 8594, the removal date) for at least 6 months before it is removed. No endpoint is deprecated today.\n\n## Tooling\nA command-line client is published as `propulsell-cli` (see https://propulsell.com/developers).\n",
    "contact": {
      "name": "PropulSell",
      "url": "https://propulsell.com"
    }
  },
  "servers": [
    {
      "url": "https://propulsell.com/api/v1",
      "description": "Production"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "Account",
      "description": "Account and credit balance introspection."
    },
    {
      "name": "Sync tools",
      "description": "Generation endpoints that return the finished result directly."
    },
    {
      "name": "Async tools",
      "description": "Generation endpoints that enqueue a background job and return 202."
    },
    {
      "name": "Jobs",
      "description": "Poll the status/result of an async job."
    },
    {
      "name": "Blog automation",
      "description": "Publish and manage blog articles from automation (n8n, translator services). Not part of the Expert Pro API: authenticated with the server-side `BLOG_API_TOKEN`, see `docs/blog.md`.\n"
    }
  ],
  "paths": {
    "/me": {
      "get": {
        "operationId": "getMe",
        "tags": [
          "Account"
        ],
        "summary": "Get the authenticated account's plan and API key metadata",
        "description": "Returns the plan of the account that owns the API key plus the key's name, prefix and created/expiry timestamps. Use it to verify a key works before calling generation endpoints.\n",
        "responses": {
          "200": {
            "description": "Account and key info.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "properties": {
                            "plan": {
                              "type": "string",
                              "nullable": true,
                              "example": "expert_pro"
                            },
                            "key_name": {
                              "type": "string",
                              "nullable": true
                            },
                            "key_prefix": {
                              "type": "string",
                              "nullable": true
                            },
                            "created_at": {
                              "type": "string",
                              "format": "date-time",
                              "nullable": true
                            },
                            "expires_at": {
                              "type": "string",
                              "format": "date-time",
                              "nullable": true
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            },
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/credits": {
      "get": {
        "operationId": "getCredits",
        "tags": [
          "Account"
        ],
        "summary": "Get remaining credit balance",
        "description": "Returns the account's current credit balance. This is the same shared pool used by the dashboard — there is no separate API allowance.\n",
        "responses": {
          "200": {
            "description": "Remaining credits.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "properties": {
                            "credits_remaining": {
                              "type": "number",
                              "example": 42
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            },
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/ad-crafter": {
      "post": {
        "operationId": "createAdCrafterListing",
        "tags": [
          "Sync tools"
        ],
        "summary": "Generate a studio-quality image + optimised listing copy (Ad Crafter)",
        "description": "Synchronous. Runs the full Ad Crafter pipeline (image enhancement + Gemini listing generation) and returns the finished result in the response. 1 credit.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "image"
                ],
                "properties": {
                  "image": {
                    "type": "string",
                    "description": "Source photo as an HTTPS URL or a base64 data URI. Resolved server-side through an SSRF-safe fetcher.\n"
                  },
                  "platform": {
                    "type": "string",
                    "nullable": true,
                    "enum": [
                      "vinted",
                      "depop",
                      "poshmark",
                      "ebay",
                      "leboncoin",
                      "vestiaire",
                      "other"
                    ],
                    "default": "other"
                  },
                  "background_type": {
                    "type": "string",
                    "nullable": true,
                    "enum": [
                      "solid",
                      "gradient",
                      "carpet",
                      "material",
                      "lifestyle"
                    ],
                    "default": "solid"
                  },
                  "background_value": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 100
                  },
                  "mannequin_type": {
                    "type": "string",
                    "enum": [
                      "none",
                      "invisible",
                      "virtual_model",
                      "mirror"
                    ],
                    "default": "none"
                  },
                  "language": {
                    "type": "string",
                    "enum": [
                      "en",
                      "fr",
                      "es",
                      "de",
                      "it",
                      "nl",
                      "pt",
                      "pl"
                    ],
                    "default": "en"
                  },
                  "custom_instructions": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 500
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Generated image + listing copy.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "properties": {
                            "generation_id": {
                              "type": "string"
                            },
                            "result_image_url": {
                              "type": "string"
                            },
                            "image_enhancement_failed": {
                              "type": "boolean"
                            },
                            "listing_title": {
                              "type": "string"
                            },
                            "listing_description": {
                              "type": "string"
                            },
                            "listing_hashtags": {
                              "type": "array",
                              "items": {
                                "type": "string"
                              }
                            },
                            "detected_brand": {
                              "type": "string",
                              "nullable": true
                            },
                            "detected_condition": {
                              "type": "string",
                              "nullable": true
                            },
                            "processing_time_ms": {
                              "type": "number"
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            },
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/InsufficientCredits"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "502": {
            "$ref": "#/components/responses/UpstreamError"
          }
        }
      }
    },
    "/beautifier": {
      "post": {
        "operationId": "createBeautifierImage",
        "tags": [
          "Sync tools"
        ],
        "summary": "Generate a beautified product image (Image Beautifier)",
        "description": "Synchronous. Runs the Beautifier pipeline and returns the result. 1 credit.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "image",
                  "category",
                  "angle",
                  "shotType"
                ],
                "properties": {
                  "image": {
                    "type": "string",
                    "description": "Source photo as an HTTPS URL or a base64 data URI."
                  },
                  "category": {
                    "type": "string",
                    "enum": [
                      "clothing",
                      "shoes",
                      "headwear",
                      "bags",
                      "glasses"
                    ]
                  },
                  "angle": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 50
                  },
                  "shotType": {
                    "type": "string",
                    "enum": [
                      "Flatlay",
                      "Hanging",
                      "On model",
                      "Ghost Mannequin",
                      "Virtual Wear",
                      "Mirror Selfie"
                    ]
                  },
                  "backgroundId": {
                    "type": "string",
                    "maxLength": 100,
                    "default": ""
                  },
                  "aspectRatio": {
                    "type": "string",
                    "maxLength": 10,
                    "default": "1:1"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Generated image.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "properties": {
                            "generation_id": {
                              "type": "string"
                            },
                            "result_image_url": {
                              "type": "string"
                            },
                            "processing_time_ms": {
                              "type": "number"
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            },
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/InsufficientCredits"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "502": {
            "$ref": "#/components/responses/UpstreamError"
          }
        }
      }
    },
    "/recoloration": {
      "post": {
        "operationId": "createRecoloration",
        "tags": [
          "Sync tools"
        ],
        "summary": "Recolor a product image to a target color (Recoloration)",
        "description": "Synchronous. Runs the Recoloration pipeline and returns the result. 1 credit.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "image",
                  "hex"
                ],
                "properties": {
                  "image": {
                    "type": "string",
                    "description": "Source photo as an HTTPS URL or a base64 data URI."
                  },
                  "hex": {
                    "type": "string",
                    "pattern": "^#[0-9A-Fa-f]{6}$",
                    "example": "#1F6FEB"
                  },
                  "color_name": {
                    "type": "string",
                    "maxLength": 100,
                    "default": "Custom Color"
                  },
                  "custom_instructions": {
                    "type": "string",
                    "maxLength": 500,
                    "default": ""
                  },
                  "aspect_ratio": {
                    "type": "string",
                    "default": "1:1"
                  },
                  "background_id": {
                    "type": "string",
                    "maxLength": 100,
                    "default": ""
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Recolored image.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "properties": {
                            "generation_id": {
                              "type": "string"
                            },
                            "result_image_url": {
                              "type": "string"
                            },
                            "color_name": {
                              "type": "string"
                            },
                            "hex": {
                              "type": "string"
                            },
                            "processing_time_ms": {
                              "type": "number"
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            },
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/InsufficientCredits"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "502": {
            "$ref": "#/components/responses/UpstreamError"
          }
        }
      }
    },
    "/photoshoots": {
      "post": {
        "operationId": "createPhotoshoot",
        "tags": [
          "Async tools"
        ],
        "summary": "Enqueue a Photo Shoot generation job",
        "description": "Asynchronous. Validates the request, deducts credits, and creates the job record, then returns `202 Accepted` with a `job_id` immediately. The actual Replicate generation and result upload continue in the background after the response is sent — poll `GET /jobs/{job_id}` for completion.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "category",
                  "productId"
                ],
                "properties": {
                  "title": {
                    "type": "string",
                    "maxLength": 100,
                    "default": "Untitled shoot"
                  },
                  "category": {
                    "type": "string",
                    "enum": [
                      "clothing",
                      "shoes",
                      "headwear",
                      "bags",
                      "glasses"
                    ]
                  },
                  "productId": {
                    "type": "string",
                    "format": "uuid",
                    "description": "ID of a product already in the caller's Products library."
                  },
                  "modelId": {
                    "type": "string",
                    "nullable": true,
                    "default": null
                  },
                  "modelLabel": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 100,
                    "default": null
                  },
                  "modelImageUrl": {
                    "type": "string",
                    "nullable": true,
                    "default": null,
                    "description": "Reference model image, as an HTTPS URL or base64 data URI — same handling as Video/Models' image-bearing fields.\n"
                  },
                  "angleId": {
                    "type": "string",
                    "nullable": true,
                    "default": null
                  },
                  "angleLabel": {
                    "type": "string",
                    "nullable": true,
                    "default": null
                  },
                  "stylingId": {
                    "type": "string",
                    "nullable": true,
                    "default": null
                  },
                  "stylingLabel": {
                    "type": "string",
                    "nullable": true,
                    "default": null
                  },
                  "stylingAssetId": {
                    "type": "string",
                    "format": "uuid",
                    "nullable": true,
                    "default": null
                  },
                  "stylingThumbPath": {
                    "type": "string",
                    "nullable": true,
                    "default": null
                  },
                  "angleAssetId": {
                    "type": "string",
                    "format": "uuid",
                    "nullable": true,
                    "default": null
                  },
                  "backgroundId": {
                    "type": "string",
                    "default": "bg1"
                  },
                  "backgroundLabel": {
                    "type": "string",
                    "nullable": true,
                    "default": null
                  },
                  "aspectRatio": {
                    "type": "string",
                    "default": "1:1"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "$ref": "#/components/responses/JobAccepted"
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/InsufficientCredits"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/video": {
      "post": {
        "operationId": "createVideo",
        "tags": [
          "Async tools"
        ],
        "summary": "Enqueue a Video generation job (Wan 2.7 R2V)",
        "description": "Asynchronous. Deducts credits (1 per 5s of duration) and creates the job record, then returns `202 Accepted` with a `job_id` immediately. Generation (which can take several minutes for 10s videos) continues in the background — poll `GET /jobs/{job_id}` for completion.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "category",
                  "productId",
                  "gestureId",
                  "gestureLabel",
                  "gesturePrompt"
                ],
                "properties": {
                  "title": {
                    "type": "string",
                    "maxLength": 100,
                    "default": "Untitled video"
                  },
                  "category": {
                    "type": "string",
                    "enum": [
                      "clothing",
                      "shoes",
                      "headwear",
                      "bags",
                      "glasses"
                    ]
                  },
                  "productId": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "modelId": {
                    "type": "string",
                    "nullable": true,
                    "default": null
                  },
                  "modelLabel": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 100,
                    "default": null
                  },
                  "modelImageUrl": {
                    "type": "string",
                    "nullable": true,
                    "default": null,
                    "description": "Reference model image, as an HTTPS URL or base64 data URI. Resolved server-side and re-encoded before being passed to the pipeline.\n"
                  },
                  "gestureId": {
                    "type": "string",
                    "minLength": 1
                  },
                  "gestureLabel": {
                    "type": "string",
                    "minLength": 1
                  },
                  "gesturePrompt": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 500,
                    "description": "Free-text gesture/motion instruction. Capped at 500 characters and isolated via prompt-injection-safe wrapping before being sent to the video model.\n"
                  },
                  "stylingId": {
                    "type": "string",
                    "nullable": true,
                    "default": null
                  },
                  "stylingLabel": {
                    "type": "string",
                    "nullable": true,
                    "default": null
                  },
                  "stylingAssetId": {
                    "type": "string",
                    "format": "uuid",
                    "nullable": true,
                    "default": null
                  },
                  "backgroundId": {
                    "type": "string",
                    "nullable": true,
                    "default": null
                  },
                  "backgroundLabel": {
                    "type": "string",
                    "nullable": true,
                    "default": null
                  },
                  "duration": {
                    "type": "integer",
                    "enum": [
                      5,
                      10
                    ],
                    "default": 5
                  },
                  "aspectRatio": {
                    "type": "string",
                    "default": "9:16"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "$ref": "#/components/responses/JobAccepted"
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/InsufficientCredits"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/models": {
      "post": {
        "operationId": "createModel",
        "tags": [
          "Async tools"
        ],
        "summary": "Enqueue an AI fashion model generation job",
        "description": "Asynchronous. Builds the prompt, deducts credits, and creates the job record, then returns `202 Accepted` with a `job_id` immediately. Generation continues in the background — poll `GET /jobs/{job_id}` for completion.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "gender",
                  "age",
                  "ethnicity",
                  "morphology",
                  "hairStyle",
                  "hairColor"
                ],
                "properties": {
                  "gender": {
                    "type": "string",
                    "enum": [
                      "female",
                      "male",
                      "other"
                    ]
                  },
                  "age": {
                    "type": "integer",
                    "minimum": 18,
                    "maximum": 80
                  },
                  "ethnicity": {
                    "type": "string",
                    "enum": [
                      "caucasian",
                      "asian",
                      "african",
                      "hispanic",
                      "middle-eastern",
                      "north-african"
                    ]
                  },
                  "morphology": {
                    "type": "string",
                    "enum": [
                      "standard",
                      "slim",
                      "athletic",
                      "plus-size"
                    ]
                  },
                  "eyeColor": {
                    "type": "string",
                    "enum": [
                      "blue",
                      "green",
                      "brown",
                      "hazel",
                      "gray",
                      "amber"
                    ],
                    "default": "brown"
                  },
                  "eyeShape": {
                    "type": "string",
                    "enum": [
                      "almond",
                      "round",
                      "monolid",
                      "hooded",
                      "downturned",
                      "upturned"
                    ],
                    "default": "almond"
                  },
                  "skinTone": {
                    "type": "string",
                    "enum": [
                      "fair",
                      "light",
                      "medium",
                      "tan",
                      "deep",
                      "rich"
                    ],
                    "default": "medium"
                  },
                  "skinUndertone": {
                    "type": "string",
                    "enum": [
                      "cool",
                      "neutral",
                      "warm",
                      "olive"
                    ],
                    "default": "neutral"
                  },
                  "hairStyle": {
                    "type": "string",
                    "enum": [
                      "long-wavy",
                      "long-straight",
                      "short-curly",
                      "short-straight",
                      "buzzcut",
                      "bun",
                      "bald",
                      "medium-curly",
                      "braids",
                      "locs",
                      "pixie",
                      "ponytail",
                      "afro",
                      "taper-fade",
                      "crew-cut",
                      "textured-crop",
                      "side-part",
                      "quiff",
                      "slick-back"
                    ]
                  },
                  "hairColor": {
                    "type": "string",
                    "enum": [
                      "blonde",
                      "brown",
                      "black",
                      "redhead",
                      "gray",
                      "dark-blonde",
                      "light-brown",
                      "dark-brown",
                      "platinum",
                      "auburn",
                      "strawberry-blonde",
                      "salt-and-pepper"
                    ]
                  },
                  "distinctiveTrait": {
                    "type": "string",
                    "enum": [
                      "none",
                      "freckles",
                      "bleach-eyebrows",
                      "heterochromia",
                      "vitiligo",
                      "big-beard"
                    ],
                    "default": "none"
                  },
                  "customInstructions": {
                    "type": "string",
                    "maxLength": 500,
                    "default": ""
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "$ref": "#/components/responses/JobAccepted"
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/InsufficientCredits"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/jobs/{id}": {
      "get": {
        "operationId": "getJob",
        "tags": [
          "Jobs"
        ],
        "summary": "Poll the status/result of an async job",
        "description": "Looks up the job by ID across the photoshoots, videoshoots, and generated_models tables (whichever one it belongs to), scoped to the authenticated caller. `status` is normalized from the underlying row's DB status column, which only ever takes the values `processing`, `completed`, or `failed` — there is no `queued` state; a job is already `processing` immediately after the `202` response.\n",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The `job_id` returned by the `202` response of an async POST endpoint."
          }
        ],
        "responses": {
          "200": {
            "description": "Current job status.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "properties": {
                            "job_id": {
                              "type": "string"
                            },
                            "status": {
                              "type": "string",
                              "enum": [
                                "processing",
                                "completed",
                                "failed"
                              ]
                            },
                            "result": {
                              "type": "object",
                              "nullable": true,
                              "description": "Present only when `status` is `completed`.",
                              "properties": {
                                "url": {
                                  "type": "string"
                                }
                              }
                            },
                            "error": {
                              "type": "object",
                              "nullable": true,
                              "description": "Present only when `status` is `failed`.",
                              "properties": {
                                "message": {
                                  "type": "string",
                                  "nullable": true
                                }
                              }
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            },
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "API-Version": {
                "$ref": "#/components/headers/ApiVersion"
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/admin/blog/posts": {
      "servers": [
        {
          "url": "https://propulsell.com/api",
          "description": "Blog automation (not under /api/v1)"
        }
      ],
      "post": {
        "tags": [
          "Blog automation"
        ],
        "operationId": "upsertBlogPost",
        "summary": "Create or update a blog post (idempotent)",
        "description": "Idempotent upsert. The target post is found by `id`, else by (`external_id`, `locale`), else by (`locale`, `slug`).\nOmitted fields are left untouched on update, so partial re-sends are safe.\n\n- Provide the body as `content_html` **or** `content_markdown` (HTML wins). Both are sanitised; external `<img>`\n  sources are re-hosted on R2.\n- `cover_image_url` / `og_image_url` (https) are downloaded, converted to WebP and stored on R2.\n- `status: published` stamps `published_at` on first publication and freezes the slug. A future `published_at`\n  makes the post `scheduled` (it goes live by itself).\n- Same `external_id` with another `locale` joins the existing translation group; `translation_of` does it explicitly.\n",
        "security": [
          {
            "blogToken": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BlogPostInput"
              },
              "example": {
                "external_id": "n8n-2026-10-20-vinted-tips",
                "locale": "en",
                "title": "10 Vinted listing tips that actually work in 2026",
                "slug": "vinted-listing-tips-2026",
                "excerpt": "From title keywords to the best time to publish: a practical guide for sellers.",
                "content_markdown": "Intro paragraph.\n\n## Use search-friendly titles\n\nText with a [link](/pricing).\n\n![A neat flat lay](https://images.example.com/flat-lay.jpg)",
                "cover_image_url": "https://images.example.com/cover.jpg",
                "cover_image_alt": "Folded clothes on a table",
                "category": "Tips",
                "tags": [
                  "vinted",
                  "listing",
                  "seo"
                ],
                "keywords": [
                  "vinted listing tips",
                  "sell clothes online"
                ],
                "author_name": "PropulSell Team",
                "meta_title": "10 Vinted listing tips that work in 2026",
                "meta_description": "Practical Vinted listing tips: titles, photos, pricing and timing, tested on thousands of listings.",
                "status": "published"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Existing post updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BlogPostResult"
                }
              }
            }
          },
          "201": {
            "description": "Post created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BlogPostResult"
                }
              }
            }
          },
          "400": {
            "description": "Validation failed (`issues` lists each field) or image import failed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BlogError"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BlogError"
                }
              }
            }
          },
          "409": {
            "description": "Slug already used in this locale, or slug change on a published post.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BlogError"
                }
              }
            }
          },
          "413": {
            "description": "Body larger than 2 MB.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BlogError"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited (120 requests/minute per IP).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BlogError"
                }
              }
            }
          },
          "503": {
            "description": "BLOG_API_TOKEN is not configured on the server.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BlogError"
                }
              }
            }
          }
        }
      },
      "get": {
        "tags": [
          "Blog automation"
        ],
        "operationId": "listBlogPosts",
        "summary": "List posts (all statuses)",
        "description": "Returns a page of blog posts of every status (draft, scheduled, published, archived) without article bodies. Filter with `q`; authenticated with the blog automation token.\n",
        "security": [
          {
            "blogToken": []
          }
        ],
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Matches title or slug."
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "draft",
                "scheduled",
                "published",
                "archived"
              ]
            }
          },
          {
            "name": "locale",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "en",
                "fr",
                "es",
                "ar"
              ]
            }
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "default": 1
            }
          },
          {
            "name": "pageSize",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 20
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of posts (without article bodies).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BlogPostList"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BlogError"
                }
              }
            }
          }
        }
      }
    },
    "/admin/blog/posts/{id}": {
      "servers": [
        {
          "url": "https://propulsell.com/api"
        }
      ],
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "tags": [
          "Blog automation"
        ],
        "operationId": "getBlogPost",
        "summary": "Get one post with its translations",
        "description": "Returns a single blog post by id, including its body and the sibling translations in the same group.",
        "security": [
          {
            "blogToken": []
          }
        ],
        "responses": {
          "200": {
            "description": "The post and its sibling translations.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BlogPostDetail"
                }
              }
            }
          },
          "404": {
            "description": "Not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BlogError"
                }
              }
            }
          }
        }
      },
      "patch": {
        "tags": [
          "Blog automation"
        ],
        "operationId": "patchBlogPost",
        "summary": "Partially update a post (publish, unpublish, schedule, archive)",
        "description": "Applies a partial update to a blog post. Use it to publish, unpublish, schedule or archive a post, or to edit individual fields. Slugs are frozen once a post is published.\n",
        "security": [
          {
            "blogToken": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BlogPostInput"
              },
              "example": {
                "status": "draft"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated post.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BlogPostResult"
                }
              }
            }
          },
          "400": {
            "description": "Validation failed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BlogError"
                }
              }
            }
          },
          "404": {
            "description": "Not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BlogError"
                }
              }
            }
          },
          "409": {
            "description": "Slug conflict or frozen slug.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BlogError"
                }
              }
            }
          }
        }
      },
      "delete": {
        "tags": [
          "Blog automation"
        ],
        "operationId": "deleteBlogPost",
        "summary": "Delete a post (soft delete)",
        "description": "Soft-deletes a blog post by id. The post disappears from the public site and sitemap.",
        "security": [
          {
            "blogToken": []
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BlogOk"
                }
              }
            }
          },
          "404": {
            "description": "Not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BlogError"
                }
              }
            }
          }
        }
      }
    },
    "/admin/blog/media": {
      "servers": [
        {
          "url": "https://propulsell.com/api"
        }
      ],
      "post": {
        "tags": [
          "Blog automation"
        ],
        "operationId": "uploadBlogImage",
        "summary": "Upload or import one image to R2",
        "description": "`multipart/form-data` with a `file` field (JPEG, PNG, WebP, AVIF, GIF; max 10 MB), or JSON `{ \"url\": \"https://…\" }` to import a remote image. Images are converted to WebP (max width 2000 px). Returns the public URL to use in article HTML, and the `path` to use as `cover_image_path`.\n",
        "security": [
          {
            "blogToken": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary"
                  }
                }
              }
            },
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url"
                ],
                "properties": {
                  "url": {
                    "type": "string",
                    "format": "uri"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Stored.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "path": {
                      "type": "string",
                      "example": "results/blog/2026/10/1790000000000_cover.webp"
                    },
                    "url": {
                      "type": "string",
                      "format": "uri"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid input or the remote host is not allowed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BlogError"
                }
              }
            }
          },
          "413": {
            "description": "Image larger than 10 MB.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BlogError"
                }
              }
            }
          },
          "415": {
            "description": "Not a readable image.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BlogError"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "headers": {
      "RateLimitLimit": {
        "description": "Requests allowed per window for this API key (`RateLimit-Policy` gives the window, 60 seconds).",
        "schema": {
          "type": "integer",
          "example": 60
        }
      },
      "RateLimitRemaining": {
        "description": "Requests left in the current window.",
        "schema": {
          "type": "integer",
          "example": 59
        }
      },
      "RateLimitReset": {
        "description": "Seconds until the current window resets.",
        "schema": {
          "type": "integer",
          "example": 42
        }
      },
      "RetryAfter": {
        "description": "Seconds to wait before retrying (sent with `429`).",
        "schema": {
          "type": "integer",
          "example": 42
        }
      },
      "RateLimitPolicy": {
        "description": "Quota policy as `<limit>;w=<window seconds>`, e.g. `60;w=60`.",
        "schema": {
          "type": "string",
          "example": "60;w=60"
        }
      },
      "ApiVersion": {
        "description": "Major API version that served the request (matches the `/v1` URL prefix).",
        "schema": {
          "type": "string",
          "example": "1"
        }
      },
      "Deprecation": {
        "description": "RFC 9745 deprecation signal: `@<unix timestamp>` from which the endpoint is deprecated. Only present on deprecated endpoints; see Versioning and deprecation.",
        "schema": {
          "type": "string",
          "example": "@1798761600"
        }
      },
      "Sunset": {
        "description": "RFC 8594 HTTP date after which the endpoint stops working. Only present on deprecated endpoints, together with `Deprecation`.",
        "schema": {
          "type": "string",
          "example": "Fri, 01 Jan 2027 00:00:00 GMT"
        }
      }
    },
    "securitySchemes": {
      "blogToken": {
        "type": "http",
        "scheme": "bearer",
        "description": "The server-side `BLOG_API_TOKEN` (at least 32 random characters). Send as `Authorization: Bearer <token>`. Only valid on the /admin/blog endpoints.\n"
      },
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "API key issued from Studio → Settings → API Keys, in the form `psl_live_<random>`. Send it as `Authorization: Bearer psl_live_<random>`. Keys are only valid while the owning account is on the Expert Pro plan.\n"
      }
    },
    "schemas": {
      "BlogPostInput": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "title"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Existing post id (editor use)."
          },
          "external_id": {
            "type": "string",
            "maxLength": 200,
            "description": "Your stable id for the article. Idempotency key together with locale."
          },
          "locale": {
            "type": "string",
            "enum": [
              "en",
              "fr",
              "es",
              "ar"
            ],
            "default": "en"
          },
          "translation_of": {
            "type": "string",
            "description": "id",
            "group_id or external_id of the post this translates.": null
          },
          "title": {
            "type": "string",
            "minLength": 3,
            "maxLength": 200
          },
          "slug": {
            "type": "string",
            "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
            "minLength": 3,
            "maxLength": 120,
            "description": "Derived from the title when omitted. Frozen once published."
          },
          "excerpt": {
            "type": "string",
            "maxLength": 500,
            "description": "Derived from the body when empty."
          },
          "content_html": {
            "type": "string",
            "maxLength": 400000
          },
          "content_markdown": {
            "type": "string",
            "maxLength": 400000,
            "description": "GitHub-flavoured markdown; \"# H1\" becomes H2."
          },
          "cover_image_url": {
            "type": "string",
            "format": "uri",
            "description": "https URL imported to R2."
          },
          "cover_image_path": {
            "type": "string",
            "nullable": true,
            "description": "Existing results/blog/… path (from /admin/blog/media)."
          },
          "cover_image_alt": {
            "type": "string",
            "nullable": true,
            "maxLength": 200
          },
          "og_image_url": {
            "type": "string",
            "format": "uri"
          },
          "og_image_path": {
            "type": "string",
            "nullable": true
          },
          "category": {
            "type": "string",
            "maxLength": 60,
            "default": "general"
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string",
              "maxLength": 60
            },
            "maxItems": 15
          },
          "keywords": {
            "type": "array",
            "items": {
              "type": "string",
              "maxLength": 80
            },
            "maxItems": 20
          },
          "author_name": {
            "type": "string",
            "maxLength": 100,
            "default": "PropulSell Team"
          },
          "meta_title": {
            "type": "string",
            "nullable": true,
            "maxLength": 120,
            "description": "Aim for 30-60 characters."
          },
          "meta_description": {
            "type": "string",
            "nullable": true,
            "maxLength": 320,
            "description": "Aim for 90-160 characters."
          },
          "canonical_url": {
            "type": "string",
            "nullable": true,
            "format": "uri",
            "description": "Only for republished content."
          },
          "noindex": {
            "type": "boolean",
            "default": false
          },
          "is_featured": {
            "type": "boolean",
            "default": false
          },
          "status": {
            "type": "string",
            "enum": [
              "draft",
              "scheduled",
              "published",
              "archived"
            ],
            "default": "draft"
          },
          "published_at": {
            "type": "string",
            "format": "date-time",
            "description": "Back-date for published",
            "go-live time for scheduled.": null
          }
        }
      },
      "BlogPostResult": {
        "type": "object",
        "properties": {
          "created": {
            "type": "boolean"
          },
          "post": {
            "type": "object",
            "description": "The stored post (camelCase), including `id`, `groupId`, `slug`, `status`, `publishedAt`, `readingMinutes`, `coverImageUrl`."
          }
        }
      },
      "Envelope": {
        "type": "object",
        "description": "Every response (success and error) is wrapped in this envelope. Exactly one of `data` / `error` is non-null.\n",
        "properties": {
          "data": {
            "nullable": true,
            "description": "Present on success; shape is endpoint-specific."
          },
          "error": {
            "nullable": true,
            "$ref": "#/components/schemas/ApiError"
          }
        },
        "required": [
          "data",
          "error"
        ]
      },
      "ApiError": {
        "type": "object",
        "description": "Typed error object. `code` is machine-readable and stable; `message` and `hint` are for humans and agents.",
        "required": [
          "code",
          "message"
        ],
        "properties": {
          "code": {
            "type": "string",
            "enum": [
              "invalid_request",
              "unauthorized",
              "insufficient_credits",
              "forbidden",
              "not_found",
              "rate_limited",
              "internal_error",
              "upstream_error"
            ]
          },
          "message": {
            "type": "string",
            "description": "Human-readable explanation of what went wrong."
          },
          "hint": {
            "type": "string",
            "description": "Resolution hint: what to change or do next before retrying."
          },
          "docs_url": {
            "type": "string",
            "format": "uri",
            "description": "Where to read the API documentation."
          }
        }
      },
      "ErrorEnvelope": {
        "type": "object",
        "description": "Envelope returned for every 4xx and 5xx response: `data` is always null and `error` is always set.",
        "required": [
          "data",
          "error"
        ],
        "properties": {
          "data": {
            "type": "null"
          },
          "error": {
            "$ref": "#/components/schemas/ApiError"
          }
        }
      },
      "BlogError": {
        "type": "object",
        "description": "Error body of the blog automation endpoints (not wrapped in the v1 envelope).",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "Human-readable error message."
          },
          "issues": {
            "type": "array",
            "description": "Per-field validation problems (400 only).",
            "items": {
              "type": "object"
            }
          }
        }
      },
      "BlogPostList": {
        "type": "object",
        "description": "A page of posts without article bodies.",
        "properties": {
          "posts": {
            "type": "array",
            "items": {
              "type": "object"
            }
          },
          "total": {
            "type": "integer"
          },
          "page": {
            "type": "integer"
          },
          "pageSize": {
            "type": "integer"
          }
        }
      },
      "BlogPostDetail": {
        "type": "object",
        "properties": {
          "post": {
            "type": "object",
            "description": "The post, including its body."
          },
          "translations": {
            "type": "array",
            "description": "Sibling translations in the same group.",
            "items": {
              "type": "object"
            }
          }
        }
      },
      "BlogOk": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean"
          }
        }
      },
      "BlogMedia": {
        "type": "object",
        "properties": {
          "path": {
            "type": "string"
          },
          "url": {
            "type": "string",
            "format": "uri"
          }
        }
      }
    },
    "responses": {
      "InvalidRequest": {
        "description": "Request body failed validation.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "data": null,
              "error": {
                "code": "invalid_request",
                "message": "Invalid request body"
              }
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing, malformed, revoked, or expired API key.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "data": null,
              "error": {
                "code": "unauthorized",
                "message": "Invalid or missing API key"
              }
            }
          }
        }
      },
      "InternalError": {
        "description": "Unexpected server-side failure.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "data": null,
              "error": {
                "code": "internal_error",
                "message": "Internal server error"
              }
            }
          }
        }
      },
      "Forbidden": {
        "description": "The account owning this key is not on the Expert Pro plan.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "data": null,
              "error": {
                "code": "forbidden",
                "message": "API access requires the Expert Pro plan"
              }
            }
          }
        }
      },
      "InsufficientCredits": {
        "description": "Not enough credits remaining in the shared credit pool.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "data": null,
              "error": {
                "code": "insufficient_credits",
                "message": "Insufficient credits"
              }
            }
          }
        }
      },
      "RateLimited": {
        "description": "Per-key rate limit exceeded. Wait `Retry-After` seconds before retrying.\n",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          },
          "RateLimit-Limit": {
            "$ref": "#/components/headers/RateLimitLimit"
          },
          "RateLimit-Remaining": {
            "$ref": "#/components/headers/RateLimitRemaining"
          },
          "RateLimit-Reset": {
            "$ref": "#/components/headers/RateLimitReset"
          },
          "RateLimit-Policy": {
            "$ref": "#/components/headers/RateLimitPolicy"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "data": null,
              "error": {
                "code": "rate_limited",
                "message": "Rate limit exceeded",
                "hint": "Wait for the number of seconds in the `Retry-After` header, then retry. Respect the `RateLimit-*` headers to self-throttle.",
                "docs_url": "https://propulsell.com/api-docs"
              }
            }
          }
        }
      },
      "UpstreamError": {
        "description": "Generation failed for a reason not exposed to API consumers (internal, DB, or upstream AI provider errors are all collapsed to this generic code so internal state isn't leaked). The credit for a failed generation is automatically refunded.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "data": null,
              "error": {
                "code": "upstream_error",
                "message": "Upstream provider error"
              }
            }
          }
        }
      },
      "JobAccepted": {
        "description": "Job accepted and enqueued; generation continues in the background.",
        "content": {
          "application/json": {
            "schema": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/Envelope"
                },
                {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "job_id": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              ]
            },
            "example": {
              "data": {
                "job_id": "b3b9e2b0-3b7a-4e9e-9b0e-2b3b7a4e9e9b"
              },
              "error": null
            }
          }
        },
        "headers": {
          "RateLimit-Limit": {
            "$ref": "#/components/headers/RateLimitLimit"
          },
          "RateLimit-Remaining": {
            "$ref": "#/components/headers/RateLimitRemaining"
          },
          "RateLimit-Reset": {
            "$ref": "#/components/headers/RateLimitReset"
          },
          "RateLimit-Policy": {
            "$ref": "#/components/headers/RateLimitPolicy"
          },
          "API-Version": {
            "$ref": "#/components/headers/ApiVersion"
          }
        }
      },
      "NotFound": {
        "description": "The resource does not exist for the authenticated caller.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "data": null,
              "error": {
                "code": "not_found",
                "message": "Resource not found"
              }
            }
          }
        }
      }
    }
  }
}