openapi: 3.0.3
info:
  title: Heymail Public Mailings API
  version: 1.0.0
  description: |
    Heymail's API for postcard sending and preview generation.

    This specification documents the externally supported integration contract only.
    Authentication is API-key based via `Authorization: Bearer <api_key>`.
servers:
  - url: https://api.heymail.com/v1
    description: Production base URL
tags:
  - name: Mailings
    description: Public postcard send and preview operations
security:
  - BearerApiKey: []
paths:
  /mailings/send:
    post:
      tags:
        - Mailings
      summary: Send a postcard mailing
      description: |
        Creates a single mailing for one or many mailItems and submits exactly one order.

        Each MailItem carries its own `recipient` (pure address) and an optional `variableData`
        object with template variables — the two are siblings, not nested. Items are always
        passed as an array; for a single postcard, send an array with one mailItem. If any mailItem
        is invalid, the whole request is rejected and no order is created.

        API keys in the `test` environment do not create a real platform order. They return the same
        response contract, but with a simulated `orderId`.
      operationId: sendMailing
      security:
        - BearerApiKey: []
      parameters:
        - $ref: '#/components/parameters/XRequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendMailingRequest'
            examples:
              singleItem:
                summary: Send to a single recipient (mailItems array with one entry)
                value:
                  templateId: "22222222-2222-4222-8222-222222222222"
                  shippingDate: "2026-04-20"
                  mailItems:
                    - recipient:
                        salutation: "Herr"
                        firstName: "Max"
                        lastName: "Mustermann"
                        street: "Testweg"
                        houseNumber: "5"
                        zip: "50667"
                        city: "Köln"
                        country: "Deutschland"
                      variableData:
                        coupon_code: "SPRING-2026"
                        post_title: "Fruehlingsaktion"
                        hero_image: "https://cdn.example.com/users/42/hero.jpg"
                  sender:
                    firstName: "Robin"
                    lastName: "Example"
                    company: "Heymail GmbH"
                    street: "Musterstrasse"
                    houseNumber: "12"
                    zip: "10115"
                    city: "Berlin"
                    country: "Deutschland"
              manyItems:
                summary: Send to many recipients in one request
                value:
                  templateId: "22222222-2222-4222-8222-222222222222"
                  shippingDate: "2026-04-20"
                  name: "Spring campaign"
                  mailItems:
                    - recipient:
                        lastName: "Mustermann"
                        street: "Testweg"
                        zip: "50667"
                        city: "Köln"
                      variableData:
                        coupon_code: "SPRING-2026"
                    - recipient:
                        company: "ACME GmbH"
                        street: "Business Park 1"
                        zip: "10115"
                        city: "Berlin"
                      variableData:
                        coupon_code: "SPRING-2026"
                  sender:
                    firstName: "Robin"
                    lastName: "Example"
                    company: "Heymail GmbH"
                    street: "Musterstrasse"
                    houseNumber: "12"
                    zip: "10115"
                    city: "Berlin"
                    country: "Deutschland"
      responses:
        '201':
          description: Mailing accepted
          headers:
            X-Request-ID:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendMailingResponse'
              examples:
                accepted:
                  value:
                    orderId: "77777777-7777-4777-8777-777777777777"
                    status: "ACCEPTED"
                    recipientCount: 2
        '400':
          description: Validation failed
          headers:
            X-Request-ID:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicApiError'
              examples:
                mailItemsMissing:
                  value:
                    type: "VALIDATION"
                    message: "Validation failed."
                    requestId: "6da3e10d-54f7-4fe4-b6db-7b66f0b9cc9a"
                    errors:
                      - field: "mailItems"
                        code: "REQUIRED"
                        message: "mailItems is required."
                mailItemRecipientInvalid:
                  value:
                    type: "VALIDATION"
                    message: "Validation failed."
                    requestId: "6da3e10d-54f7-4fe4-b6db-7b66f0b9cc9a"
                    errors:
                      - index: 149
                        field: "recipient.zip"
                        code: "REQUIRED"
                        message: "recipient.zip is required."
                mailItemVariableDataInvalid:
                  value:
                    type: "VALIDATION"
                    message: "Validation failed."
                    requestId: "6da3e10d-54f7-4fe4-b6db-7b66f0b9cc9a"
                    errors:
                      - index: 3
                        field: "variableData.coupon_code"
                        code: "MAX_LENGTH"
                        message: "variableData.coupon_code exceeds maximum length of 2048."
                mailItemsTooLarge:
                  value:
                    type: "VALIDATION"
                    message: "Validation failed."
                    requestId: "6da3e10d-54f7-4fe4-b6db-7b66f0b9cc9a"
                    errors:
                      - field: "mailItems"
                        code: "MAX_ITEMS"
                        message: "mailItems must not contain more than 10000 entries."
        '401':
          $ref: '#/components/responses/AuthenticationError'
        '403':
          $ref: '#/components/responses/AuthorizationError'
        '413':
          $ref: '#/components/responses/PayloadTooLargeError'
        '415':
          $ref: '#/components/responses/UnsupportedMediaTypeError'
        '422':
          description: Domain validation failed
          headers:
            X-Request-ID:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicApiError'
              examples:
                senderMissing:
                  summary: No sender available
                  value:
                    type: "VALIDATION"
                    message: "Sender missing. Provide sender or configure a default sender."
                    requestId: "6da3e10d-54f7-4fe4-b6db-7b66f0b9cc9a"
                shippingDateBeforeNext:
                  summary: Shipping date before next available shipping date
                  value:
                    type: "VALIDATION"
                    message: "shippingDate is invalid."
                    requestId: "6da3e10d-54f7-4fe4-b6db-7b66f0b9cc9a"
                    errors:
                      - field: "shippingDate"
                        code: "BEFORE_NEXT_SHIPPING_DATE"
                        message: "shippingDate must not be earlier than the next available shipping date."
                shippingDateRestricted:
                  summary: Shipping date is blocked
                  value:
                    type: "VALIDATION"
                    message: "shippingDate is invalid."
                    requestId: "6da3e10d-54f7-4fe4-b6db-7b66f0b9cc9a"
                    errors:
                      - field: "shippingDate"
                        code: "RESTRICTED_DAY"
                        message: "shippingDate is not available for shipping."
        '429':
          $ref: '#/components/responses/RateLimitError'
        '500':
          $ref: '#/components/responses/InternalError'
  /mailings/preview:
    post:
      tags:
        - Mailings
      summary: Generate a transient preview
      description: Generates a preview URL without creating a mailing.
      operationId: previewMailing
      security:
        - BearerApiKey: []
      parameters:
        - $ref: '#/components/parameters/XRequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PreviewRequest'
            examples:
              preview:
                value:
                  templateId: "22222222-2222-4222-8222-222222222222"
                  mailItem:
                    recipient:
                      salutation: "Herr"
                      firstName: "Max"
                      lastName: "Mustermann"
                      street: "Testweg"
                      houseNumber: "5"
                      zip: "50667"
                      city: "Köln"
                      country: "Deutschland"
                    variableData:
                      coupon_code: "SPRING-2026"
                      hero_image: "https://cdn.example.com/users/42/hero.jpg"
                  sender:
                    firstName: "Robin"
                    lastName: "Example"
                    company: "Heymail GmbH"
                    street: "Musterstrasse"
                    houseNumber: "12"
                    zip: "10115"
                    city: "Berlin"
                    country: "Deutschland"
      responses:
        '200':
          description: Preview generated
          headers:
            X-Request-ID:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PreviewResponse'
              examples:
                ok:
                  value:
                    previewUrl: "https://app.heymail.com/preview/document.pdf"
        '400':
          description: Validation failed
          headers:
            X-Request-ID:
              $ref: '#/components/headers/XRequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicApiError'
              examples:
                invalidTemplateId:
                  value:
                    type: "VALIDATION"
                    message: "Validation failed."
                    requestId: "6da3e10d-54f7-4fe4-b6db-7b66f0b9cc9a"
                    errors:
                      - field: "templateId"
                        message: "Invalid value"
                unknownTopLevelField:
                  value:
                    type: "VALIDATION"
                    message: "Validation failed."
                    requestId: "6da3e10d-54f7-4fe4-b6db-7b66f0b9cc9a"
                    errors:
                      - field: "shippingDate"
                        code: "UNKNOWN_FIELD"
                        message: "shippingDate is not allowed."
        '401':
          $ref: '#/components/responses/AuthenticationError'
        '403':
          $ref: '#/components/responses/AuthorizationError'
        '413':
          $ref: '#/components/responses/PayloadTooLargeError'
        '415':
          $ref: '#/components/responses/UnsupportedMediaTypeError'
        '429':
          $ref: '#/components/responses/RateLimitErrorPreview'
        '500':
          $ref: '#/components/responses/InternalError'
components:
  securitySchemes:
    BearerApiKey:
      type: http
      scheme: bearer
      bearerFormat: API Key
      description: |
        Opaque Heymail API key passed as `Authorization: Bearer <api_key>`.
        Supported key environments are `live` and `test`.
  parameters:
    XRequestId:
      in: header
      name: X-Request-ID
      required: false
      schema:
        type: string
      description: Optional caller-provided request identifier. If omitted, the server generates one.
  headers:
    XRequestId:
      description: Server-generated or forwarded request identifier.
      schema:
        type: string
  responses:
    ValidationError:
      description: Validation failed
      headers:
        X-Request-ID:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/PublicApiError'
          examples:
            recipientMissing:
              value:
                type: "VALIDATION"
                message: "Validation failed."
                requestId: "6da3e10d-54f7-4fe4-b6db-7b66f0b9cc9a"
                errors:
                  - field: "recipient"
                    code: "REQUIRED"
                    message: "recipient is required."
            metadataRejected:
              value:
                type: "VALIDATION"
                message: "Validation failed."
                requestId: "6da3e10d-54f7-4fe4-b6db-7b66f0b9cc9a"
                errors:
                  - field: "metadata"
                    code: "UNKNOWN_FIELD"
                    message: "metadata is not allowed."
    ValidationError422:
      description: Domain validation failed
      headers:
        X-Request-ID:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/PublicApiError'
    AuthenticationError:
      description: Authentication failed
      headers:
        X-Request-ID:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/PublicApiError'
          examples:
            missingApiKey:
              value:
                type: "AUTHENTICATION"
                message: "API key required."
                requestId: "6da3e10d-54f7-4fe4-b6db-7b66f0b9cc9a"
            invalidApiKey:
              value:
                type: "AUTHENTICATION"
                message: "Invalid or revoked API key."
                requestId: "6da3e10d-54f7-4fe4-b6db-7b66f0b9cc9a"
    AuthorizationError:
      description: Caller is authenticated but not authorized
      headers:
        X-Request-ID:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/PublicApiError'
          examples:
            missingScope:
              value:
                type: "AUTHORIZATION"
                message: "Missing required scope: mailings:send."
                requestId: "6da3e10d-54f7-4fe4-b6db-7b66f0b9cc9a"
            unverifiedUser:
              value:
                type: "AUTHORIZATION"
                message: "Verified account required."
                requestId: "6da3e10d-54f7-4fe4-b6db-7b66f0b9cc9a"
            publicApiDisabled:
              value:
                type: "AUTHORIZATION"
                message: "Public API access is not enabled for this account."
                requestId: "6da3e10d-54f7-4fe4-b6db-7b66f0b9cc9a"
            originBlocked:
              value:
                type: "AUTHORIZATION"
                message: "Origin not allowed."
                requestId: "6da3e10d-54f7-4fe4-b6db-7b66f0b9cc9a"
    PayloadTooLargeError:
      description: Request body exceeds the allowed size (2 MB)
      headers:
        X-Request-ID:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/PublicApiError'
          example:
            type: "PAYLOAD_TOO_LARGE"
            message: "Request body exceeds allowed size."
            requestId: "6da3e10d-54f7-4fe4-b6db-7b66f0b9cc9a"
    UnsupportedMediaTypeError:
      description: Unsupported content type
      headers:
        X-Request-ID:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/PublicApiError'
          example:
            type: "UNSUPPORTED_MEDIA_TYPE"
            message: "Content-Type must be application/json."
            requestId: "6da3e10d-54f7-4fe4-b6db-7b66f0b9cc9a"
    RateLimitError:
      description: Too many send requests
      headers:
        X-Request-ID:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/PublicApiError'
          example:
            type: "RATE_LIMIT"
            message: "Too many send requests. Please retry later."
            requestId: "6da3e10d-54f7-4fe4-b6db-7b66f0b9cc9a"
    RateLimitErrorPreview:
      description: Too many preview requests
      headers:
        X-Request-ID:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/PublicApiError'
          example:
            type: "RATE_LIMIT"
            message: "Too many preview requests. Please retry later."
            requestId: "6da3e10d-54f7-4fe4-b6db-7b66f0b9cc9a"
    InternalError:
      description: Unexpected server-side failure
      headers:
        X-Request-ID:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/PublicApiError'
          example:
            type: "INTERNAL"
            message: "Internal server error"
            requestId: "6da3e10d-54f7-4fe4-b6db-7b66f0b9cc9a"
  schemas:
    SendMailingRequest:
      type: object
      additionalProperties: false
      required:
        - templateId
        - mailItems
      properties:
        templateId:
          type: string
          format: uuid
        mailItems:
          type: array
          minItems: 1
          maxItems: 10000
          description: |
            Mail items of the mailing. Each MailItem carries a `recipient` (pure address) and an
            optional `variableData` object as siblings. Always an array — for a single
            postcard pass an array with one mailItem.
          items:
            $ref: '#/components/schemas/MailItem'
        sender:
          $ref: '#/components/schemas/Sender'
        shippingDate:
          type: string
          nullable: true
          pattern: '^\d{4}-\d{2}-\d{2}$'
          description: |
            Optional shipping date in `YYYY-MM-DD` format.
            If omitted or `null`, Heymail automatically uses the next available shipping date.
        name:
          type: string
          description: Optional internal mailing name. Not required for normal integrations.
    MailItem:
      type: object
      additionalProperties: false
      required:
        - recipient
      properties:
        recipient:
          $ref: '#/components/schemas/Recipient'
        variableData:
          $ref: '#/components/schemas/VariableData'
    PreviewRequest:
      type: object
      additionalProperties: false
      required:
        - templateId
        - mailItem
      properties:
        templateId:
          type: string
          format: uuid
        mailItem:
          $ref: '#/components/schemas/MailItem'
          description: |
            The MailItem to render. Same shape and same recipient
            validation as a single entry in `/mailings/send` — `recipient`
            with `street`, `zip`, `city` and either `lastName` or `company`
            is required. This keeps preview and send consistent: anything
            that renders as a preview can also be sent.
        sender:
          $ref: '#/components/schemas/Sender'
    SendMailingResponse:
      type: object
      additionalProperties: false
      required:
        - orderId
        - status
        - recipientCount
      properties:
        orderId:
          type: string
          description: |
            Platform order ID for live keys or simulated order ID for test keys.
        status:
          type: string
          enum:
            - ACCEPTED
        recipientCount:
          type: integer
          minimum: 1
    PreviewResponse:
      type: object
      additionalProperties: false
      required:
        - previewUrl
      properties:
        previewUrl:
          type: string
          format: uri
    PublicApiError:
      type: object
      additionalProperties: false
      required:
        - type
        - message
      properties:
        type:
          type: string
          enum:
            - AUTHENTICATION
            - AUTHORIZATION
            - VALIDATION
            - RATE_LIMIT
            - PAYLOAD_TOO_LARGE
            - UNSUPPORTED_MEDIA_TYPE
            - NOT_FOUND
            - INTERNAL
        message:
          type: string
        requestId:
          type: string
        errors:
          $ref: '#/components/schemas/ValidationErrors'
    ValidationErrors:
      type: array
      items:
        $ref: '#/components/schemas/ValidationErrorDetail'
    ValidationErrorDetail:
      type: object
      additionalProperties: false
      required:
        - field
        - message
      properties:
        index:
          type: integer
          minimum: 0
        field:
          type: string
        code:
          type: string
        message:
          type: string
    Sender:
      allOf:
        - $ref: '#/components/schemas/Address'
      description: |
        Optional sender address. If omitted in `/send`, Heymail resolves the user's default sender.
    Recipient:
      allOf:
        - $ref: '#/components/schemas/Address'
      description: |
        Recipient object — a pure postal address. `street`, `zip`, `city` and either `lastName` or
        `company` are required by business validation. Template variables never live inside the
        Recipient — they always sit alongside it as `variableData` within a `MailItem`.
    Address:
      type: object
      additionalProperties: false
      properties:
        salutation:
          type: string
          maxLength: 256
        title:
          type: string
          maxLength: 256
        firstName:
          type: string
          maxLength: 256
        lastName:
          type: string
          maxLength: 256
        company:
          type: string
          maxLength: 256
        street:
          type: string
          maxLength: 256
        houseNumber:
          type: string
          maxLength: 256
        addressAddition:
          type: string
          maxLength: 256
        zip:
          type: string
          maxLength: 256
        city:
          type: string
          maxLength: 256
        country:
          type: string
          maxLength: 256
    VariableData:
      type: object
      description: |
        Template variable payload. Keys reference variables defined inside
        the Heymail template editor.

        **Key normalization (server-side):** the editor stores every
        user-defined variable under a *normalized* key — the display
        name as entered, lowercased, umlauts ASCII-fied
        (`ä→ae`, `ö→oe`, `ü→ue`, `ß→ss`), every other character
        collapsed to `_`. The API applies the same rule to incoming
        keys before lookup, so payload keys that normalize to the
        template's stored key all match the same slot.

        Examples (assume a template variable created with display name
        `Coupon Code`, stored under key `coupon_code`):

        ```
        "coupon_code"   → coupon_code   ✓ (already normalized)
        "Coupon Code"   → coupon_code   ✓
        "coupon-code"   → coupon_code   ✓
        "COUPON_CODE"   → coupon_code   ✓
        "couponCode"    → couponcode    ✗ (no separator, normalizes differently)
        ```

        Recommendation: send the canonical normalized form (the key
        shown in the editor). Other shapes are accepted as a
        convenience but the rule above must hold.

        **Collisions are rejected.** Sending two distinct keys that
        normalize to the same form (e.g. `coupon-code` *and*
        `coupon_code`) returns `400 VALIDATION` with `code: DUPLICATE_KEY`
        — silent last-write-wins would mask the integrator's bug.

        **Values: text or image URL.** Values are strings. Beyond
        plain text (e.g. coupon codes, names), a value can be an
        **image URL** that resolves into an image block of the template.
        Image-URL variables must be defined in the template AND bound
        to an image block via the template editor. Without that
        binding, the value is treated as text only.

        **Image URL requirements:**
        - Must use `https://` scheme. Other schemes (`http`, `data`,
          `javascript`, `file`, protocol-relative `//`) are rejected
          by the renderer and result in an empty `src` attribute.
        - Maximum length: 2048 characters (covers most signed URLs).
        - Publicly reachable from our renderer infrastructure, or
          allow-listed via IP. Internal/private hosts (`localhost`,
          RFC 1918 ranges, link-local) are rejected for SSRF safety.
        - Content-Type returned by the host must be `image/*`.

        Image variables are not yet configurable through the editor UI
        in all environments. For early integrations the binding has to
        be set administratively — contact support with the template ID
        and the desired variable key.

        Limits per request:
        - Each key: up to 256 characters (length is unchanged by normalization).
        - Each value: up to 2048 characters, must be a string.
        - Overall request size: 2 MB (enforced by the body limit).
      additionalProperties:
        type: string
        maxLength: 2048
