openapi: 3.2.0 info: title: thirds.ai Batches API version: 1.0.0 description: Turn one design into content at scale. Create branded images and PDFs for your campaigns and clients. Automate each new version through our API or your AI tools. Render saved templates with new data, or send HTML directly. Every error uses one envelope, every response carries an x-request-id header, and every JSON request body rejects fields it does not expect. servers: - url: https://thirds.ai tags: - name: Batches paths: /v1/batches: post: summary: Create a render batch description: 'Render one saved template version once per data row as PDF, PNG, JPEG, or WebP. The account plan sets how many rows one run accepts: 10 on Free and a pack-only account, 50 on Starter, 100 on Growth, and 200 on Scale. A run over that limit answers 400 batch_row_limit with error.plan_limit. Validates every row against the version''s schema before any job exists. Creates one normal render job per row as account and key concurrency allow. Remaining rows stay pending until a later retry or the background drain. The ledger reserves one credit per admitted job, settles it on success, and releases it on failure or cancellation. quoted_credits is the row count, not a discount.' operationId: createBatch security: - bearerAuth: [] parameters: - name: Idempotency-Key in: header required: true schema: type: string minLength: 1 maxLength: 255 pattern: ^[ -~]+$ requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/BatchCreate' examples: certificates: value: template_id: tpl_2f6a2f6a2f6a2f6a2f6a2f6a2f6a2f6a version: 1 format: pdf rows: - issuer: Fieldnote Learning learner: Alex Morgan course: Clear plans date: 4 September 2026 - issuer: Fieldnote Learning learner: Theo Lee course: Clear plans date: 4 September 2026 responses: '201': description: The batch was created, or an earlier request with the same idempotency key and body is replayed. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/Batch' '400': $ref: '#/components/responses/Error' '401': $ref: '#/components/responses/Error' '403': $ref: '#/components/responses/AccountSuspended' '404': $ref: '#/components/responses/Error' '409': $ref: '#/components/responses/Error' '413': $ref: '#/components/responses/Error' '415': $ref: '#/components/responses/Error' '429': $ref: '#/components/responses/Error' '500': $ref: '#/components/responses/Error' '503': $ref: '#/components/responses/Error' '405': $ref: '#/components/responses/MethodNotAllowed' '431': $ref: '#/components/responses/RequestHeadersTooLarge' tags: - Batches /v1/batches/{id}: parameters: - name: id in: path required: true schema: type: string pattern: ^batch_[0-9a-f]{32}$ get: summary: Get a render batch's status description: Returns the batch with each row's own state and, once any row has succeeded, a signed archive_url for the ZIP of every succeeded file. GET never starts billed work. operationId: getBatch security: - bearerAuth: [] responses: '200': description: The batch's current status. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/Batch' '401': $ref: '#/components/responses/Error' '403': $ref: '#/components/responses/AccountSuspended' '404': $ref: '#/components/responses/Error' '500': $ref: '#/components/responses/Error' '405': $ref: '#/components/responses/MethodNotAllowed' '431': $ref: '#/components/responses/RequestHeadersTooLarge' tags: - Batches /v1/batches/{id}/retry: post: summary: Retry a batch's failed, cancelled, or pending rows description: Creates one new job for every row whose last attempt failed or was cancelled, and admits still-pending rows as concurrency allows. A queued, running, or already succeeded row is left untouched. Retry never bills a row that already succeeded. operationId: retryBatch security: - bearerAuth: [] parameters: - name: id in: path required: true schema: type: string pattern: ^batch_[0-9a-f]{32}$ responses: '200': description: The batch after retry, with a new job for each row that was retried. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/BatchRetry' '401': $ref: '#/components/responses/Error' '403': $ref: '#/components/responses/AccountSuspended' '404': $ref: '#/components/responses/Error' '429': $ref: '#/components/responses/Error' '500': $ref: '#/components/responses/Error' '405': $ref: '#/components/responses/MethodNotAllowed' '431': $ref: '#/components/responses/RequestHeadersTooLarge' tags: - Batches /v1/batches/{id}/cancel: post: summary: Cancel a batch's pending and queued rows description: Stops rows that have no job yet and cancels queued jobs. Running jobs finish. Succeeded files stay downloadable. Cancelled and failed rows cost nothing. operationId: cancelBatch security: - bearerAuth: [] parameters: - name: id in: path required: true schema: type: string pattern: ^batch_[0-9a-f]{32}$ responses: '200': description: The batch after cancel. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/Batch' '401': $ref: '#/components/responses/Error' '403': $ref: '#/components/responses/AccountSuspended' '404': $ref: '#/components/responses/Error' '500': $ref: '#/components/responses/Error' '405': $ref: '#/components/responses/MethodNotAllowed' '431': $ref: '#/components/responses/RequestHeadersTooLarge' tags: - Batches /v1/batches/archive/{token}: get: summary: Download a batch's ZIP archive description: Stream a ZIP of one batch's succeeded rows, in requested order with numbered filenames such as 001.pdf, plus manifest.json recording that order and any failed or cancelled rows. The link carries its own authority, so no other credential is read. The manifest never includes customer data. operationId: getBatchArchive parameters: - name: token in: path required: true description: The signed archive link. schema: type: string responses: '405': $ref: '#/components/responses/MethodNotAllowed' '431': $ref: '#/components/responses/RequestHeadersTooLarge' '200': description: The ZIP archive of succeeded rows plus manifest.json. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/zip: schema: type: string format: binary '403': description: The link is genuine but its time is over. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' '404': description: The link is not valid, or the batch has no succeeded row to archive. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' '500': description: An internal error occurred. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' tags: - Batches components: responses: MethodNotAllowed: description: The method is not allowed on this route. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' AccountSuspended: description: The authenticated account is suspended. The code is account_suspended. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' RequestHeadersTooLarge: description: The request has more than 64 headers or more than 32 KiB of header names and values. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' Error: description: The one error envelope every backend response uses. headers: x-request-id: $ref: '#/components/headers/XRequestId' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' schemas: ErrorEnvelope: type: object description: The one error shape every backend response uses. properties: error: type: object properties: code: type: string enum: - account_suspended - account_concurrency_limited - abuse_limited - ai_failure_limit_reached - ai_needs_paid_credits - already_subscribed - auth_unavailable - batch_row_limit - billing_unavailable - brand_asset_account_limit_reached - brand_asset_invalid - brand_asset_kind_invalid - brand_asset_kind_mismatch - brand_asset_limit_reached - brand_asset_too_large - brand_asset_type_unsupported - brand_data_conflict - brand_font_glyphs_exceeded - brand_font_tables_invalid - brand_image_animated - brand_image_dimensions_invalid - brand_image_pixels_exceeded - brand_kit_colours_invalid - brand_kit_conflict - brand_kit_limit - brand_kit_name_invalid - brand_kit_not_found - brand_kit_patch_empty - brand_kit_tone_invalid - brand_kit_unavailable - captcha_rejected - checkout_superseded - cross_origin_rejected - csrf_rejected - download_expired - email_already_set - gallery_template_not_found - generated_template_invalid - idempotency_conflict - image_asset_header_unsupported - image_asset_invalid - image_asset_limit - image_asset_not_found - image_asset_reference_invalid - image_asset_too_large - image_asset_type_unsupported - image_asset_unavailable - image_url_invalid - image_url_unavailable - insufficient_credits - internal_error - invalid_cursor - invalid_email - invalid_event - invalid_link - invalid_profile - invalid_request - invalid_upload - job_not_finished - key_concurrency_limited - key_limit_reached - method_not_allowed - no_billing_customer - not_found - operation_conflict - operation_limit_exceeded - operation_pending - overage_limit_reached - overage_unavailable - overloaded - playground_busy - playground_request_invalid - playground_selection_invalid - playground_session_limited - playground_unavailable - policy_version_stale - provider_unavailable - rate_limited - render_probe_busy - render_probe_failed - render_probe_not_configured - render_probe_required - render_probe_timeout - request_headers_too_large - request_too_large - resize_timeout - signed_out - spend_cap_reached - template_build_not_found - template_data_collection_limit - template_data_depth_limit - template_data_invalid - template_data_limit - template_depth_limit - template_draft_not_found - template_evaluation_error - template_invalid_filter_input - template_missing_data - template_output_limit - template_not_found - template_schema_complexity - template_schema_draft_unsupported - template_schema_invalid - template_schema_too_large - template_size_canvas_mismatch - template_size_data_overrides_too_large - template_size_duplicate_id - template_size_invalid_dimensions - template_size_invalid_id - template_size_invalid_name - template_sizes_too_large - template_sizes_too_many - template_source_limit - template_syntax_error - template_timeout - template_version_changed - template_work_limit - testimonial_busy - testimonial_invalid - testimonial_rate_limited - testimonial_unavailable - unauthorized - unsupported_media_type - webhook_limit_reached description: A fixed, machine-readable error code. message: type: string description: A fixed, human-readable message. request_id: type: string format: uuid description: The identifier this answer also carries in its x-request-id header. details: type: array description: Present on a validation failure. Schema failures return at most 16 entries with bounded data paths and fixed reasons. Values from the request are never repeated. items: $ref: '#/components/schemas/FieldDetail' retry: $ref: '#/components/schemas/RetryInfo' plan_limit: $ref: '#/components/schemas/PlanLimitInfo' required: - code - message - request_id additionalProperties: false required: - error additionalProperties: false TemplateId: type: string pattern: ^tpl_[0-9a-f]{32}$ RetryInfo: type: object description: How long the caller must wait before it retries. properties: retry_after_seconds: type: integer minimum: 1 maximum: 60 required: - retry_after_seconds additionalProperties: false Batch: type: object properties: id: type: string pattern: ^batch_[0-9a-f]{32}$ template_id: $ref: '#/components/schemas/TemplateId' version: type: integer minimum: 1 format: type: string enum: - pdf - png - jpeg - webp created_at: type: string format: date-time state: type: string enum: - queued - running - complete - partial - failed - cancelled quoted_credits: type: integer minimum: 1 maximum: 200 description: The row count. Each successful file settles one credit. Failed and cancelled rows cost nothing. There is no bulk discount. rows: type: array items: $ref: '#/components/schemas/BatchRow' archive_url: type: - string - 'null' description: A signed ZIP download link, present once any row has succeeded. required: - id - template_id - version - format - created_at - state - quoted_credits - rows - archive_url additionalProperties: false FieldDetail: type: object description: One request field that failed, its fixed safe reason, and an optional bounded source location. Details never carry template source, customer values, rendered output, or raw evaluator prose. properties: field: type: string maxLength: 260 description: The path to the field, such as "pdf.scale". Schema errors use data followed by a JSON Pointer, such as data/items/0/count. The pointer is cut at 256 UTF-8 bytes. Empty for a problem with the whole document. reason: type: string enum: - malformed JSON - missing field - unknown field - wrong type - invalid value - A required value is missing. - Use the expected value type. - Declare this variable before using it. - Choose an allowed value. - Use the required format. - Add a value. - Use a shorter value. - Use a number within the allowed range. - Check this value against its data rule. description: A fixed, safe reason. It never repeats the value the caller sent. line: type: integer minimum: 1 maximum: 1000000 description: The one-based template source line when the evaluator provides one within the published bound. column: type: integer minimum: 1 maximum: 1000000 description: The one-based template source column when the evaluator provides one within the published bound. required: - field - reason additionalProperties: false BatchRow: type: object properties: position: type: integer minimum: 0 state: type: string enum: - queued - running - succeeded - failed - cancelled error: type: - object - 'null' properties: code: type: string required: - code additionalProperties: false download_url: type: - string - 'null' required: - position - state - error - download_url additionalProperties: false BatchCreate: type: object properties: template_id: $ref: '#/components/schemas/TemplateId' version: type: integer minimum: 1 description: Omitted to use the template's latest version at creation time. The batch pins that exact version. format: type: string enum: - pdf - png - jpeg - webp rows: type: array items: type: object minItems: 1 maxItems: 200 description: 1 to 200 JSON objects. Each object is one render. 200 is the system cap; the account plan can allow fewer. The CSV parser lives in the studio; this API accepts rows only. required: - template_id - format - rows additionalProperties: false BatchRetry: $ref: '#/components/schemas/Batch' PlanLimitInfo: type: object description: 'Present when the account''s plan causes the refusal: brand_kit_limit and batch_row_limit. It names the limit and the plan that sets it, so a caller can act without a second request. The message text never names the number.' properties: plan: type: string enum: - scale - growth - starter - pack - free description: The account's plan at the time of the refusal. limit: type: integer minimum: 1 description: 'What that plan allows: brand kits for brand_kit_limit, rows in one run for batch_row_limit.' required: - plan - limit additionalProperties: false headers: XRequestId: description: The UUID that identifies this request and matches error.request_id on an error response. required: true schema: type: string format: uuid securitySchemes: sessionCookie: type: apiKey in: cookie name: __Host-thirds_session description: A browser session. Browser writes also require the matching x-csrf-token header from GET /v1/me. bearerAuth: type: http scheme: bearer description: 'An API key''s secret, sent as "Authorization: Bearer thirds_sk_v1_...".' x-unmatched-v1-responses: description: A request below /v1 that matches no operation receives the shared safe envelope. OpenAPI has no standard path item for an unmatched route, so this extension records the fallback contract without claiming that a catch-all operation exists. '404': $ref: '#/components/responses/NotFound' '431': $ref: '#/components/responses/RequestHeadersTooLarge'