openapi: 3.1.0
info:
  title: CopyLoop Application Sources API
  version: 1.0.0-private-preview
  description: |
    Trusted-backend API for identifying email-addressable contacts, recording
    immutable customer events, recording evidence-bearing consent decisions,
    and discovering contact-list destinations. Accepted signals never grant
    authority to send or publish.
servers:
  - url: https://api.copyloop.com
security:
  - applicationSourceBearer: []
x-copyloop-limits:
  requestBodyBytes:
    maximum: 65536
  burstRequests:
    maximum: 600
    windowSeconds: 60
    description: Applied independently to the credential key and originating IP. Retry-After is authoritative.
  timestamps:
    oldestYears: 10
    futureSkewSeconds: 600
paths:
  /v1/webhooks/{keyId}:
    post:
      operationId: receiveContactWebhook
      summary: Identify an email-addressable contact from a low-code webhook
      description: |
        Tolerant contact-only adapter for Zapier, Make, n8n, form builders, and similar
        tools. Unknown fields are ignored. The adapter translates accepted input into the
        same canonical contact.identify operation and receipt model as the Developer API.
      security:
        - webhookApiKey: []
      parameters:
        - name: keyId
          in: path
          required: true
          schema:
            type: string
            pattern: '^[0-9a-f]{32}$'
        - name: Idempotency-Key
          in: header
          required: false
          description: |
            Stable retry key when the sending tool can supply one. Without this header,
            contact state remains idempotent by normalized email but transport retries may
            create more than one receipt. Retry equality uses only the normalized accepted
            email and first/last-name fields; ignored fields and server-derived observation
            metadata do not change it. Reusing a key with different accepted content returns
            409 idempotency_conflict.
          schema:
            type: string
            minLength: 1
            maxLength: 200
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookContactRequest'
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/WebhookContactRequest'
      responses:
        '202':
          $ref: '#/components/responses/Accepted'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/Unavailable'
  /v1/contact-lists:
    get:
      operationId: listContactLists
      summary: List active contact-list destinations
      description: |
        Returns the active contact lists in the authenticated Developer API source's
        workspace that may be referenced by addToListIds. This read-only discovery
        operation does not return contacts, membership counts, deleted lists, or
        credentials, and it cannot create or modify a list.
      responses:
        '200':
          description: Active contact-list destinations
          headers:
            Cache-Control:
              schema:
                type: string
                const: private, no-store
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactListCollection'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/Unavailable'
  /v1/contacts/identify:
    post:
      operationId: identifyContact
      summary: Identify an email-addressable source user
      description: |
        Creates or updates the source-scoped identity observation. Optional
        addToListIds destinations are additive, workspace-scoped, and transactional;
        they do not grant consent or replace existing memberships.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/IdentifyRequest'
      responses:
        '202':
          $ref: '#/components/responses/Accepted'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/Unavailable'
  /v1/events:
    post:
      operationId: recordEvent
      summary: Record an immutable customer event
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EventRequest'
      responses:
        '202':
          $ref: '#/components/responses/Accepted'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/Unavailable'
  /v1/consent:
    post:
      operationId: recordConsent
      summary: Record an evidence-bearing consent decision
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ConsentRequest'
      responses:
        '202':
          $ref: '#/components/responses/Accepted'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/Unavailable'
  /v1/receipts/{receiptId}:
    get:
      operationId: getReceipt
      summary: Read sanitized receipt status for the authenticated source
      parameters:
        - name: receiptId
          in: path
          required: true
          schema:
            type: string
            pattern: '^rcpt_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
      responses:
        '200':
          description: Source-scoped receipt status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Receipt'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/Unavailable'
components:
  securitySchemes:
    applicationSourceBearer:
      type: http
      scheme: bearer
      bearerFormat: cl_src_<key-id>_<secret>
      description: Private server-side Application Source credential. Never embed in browser or mobile code.
    webhookApiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        Private secret for one webhook endpoint. The endpoint key ID in the URL and this
        secret are both required. Never expose either value in browser or mobile code.
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: Stable opaque retry key for this operation. It must not contain personal data.
      schema:
        type: string
        minLength: 1
        maxLength: 200
        pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$'
  responses:
    Accepted:
      description: Durably admitted by CopyLoop
      headers:
        Location:
          required: true
          schema:
            type: string
            pattern: '^/v1/receipts/rcpt_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/AcceptedResponse'
    BadRequest:
      description: Malformed HTTP, JSON, or idempotency header
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Unauthorized:
      description: Missing, invalid, expired, or revoked credential
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Forbidden:
      description: Operation not allowed or source lifecycle prevents new admission
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    NotFound:
      description: Receipt absent or owned by another source
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Conflict:
      description: Transport or domain identifier was reused with different content
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    PayloadTooLarge:
      description: Request exceeds 65,536 bytes
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Unprocessable:
      description: Authenticated request does not satisfy the closed operation schema
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    RateLimited:
      description: Source daily quota or credential/IP burst capacity exceeded
      headers:
        Retry-After:
          schema:
            type: integer
            minimum: 1
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Unavailable:
      description: CopyLoop could not durably admit or serve the request
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
  schemas:
    ContactListBrand:
      type: object
      additionalProperties: false
      required: [id, name]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          minLength: 1
          maxLength: 255
    ContactListDestination:
      type: object
      additionalProperties: false
      required: [id, name, brand]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          minLength: 1
          maxLength: 255
        brand:
          description: Brand metadata when the contact list is associated with a brand.
          oneOf:
            - $ref: '#/components/schemas/ContactListBrand'
            - type: 'null'
    ContactListCollection:
      type: object
      additionalProperties: false
      required: [contactLists]
      properties:
        contactLists:
          type: array
          items:
            $ref: '#/components/schemas/ContactListDestination'
    WebhookContactRequest:
      type: object
      description: |
        A valid email field is required. Snake-case and camel-case aliases are accepted for
        the governed standard fields below; all other fields are ignored in webhook v1.
      additionalProperties: true
      anyOf:
        - required: [email]
        - required: [email_address]
        - required: [emailAddress]
      properties:
        email:
          type: string
          format: email
          maxLength: 320
        email_address:
          type: string
          format: email
          maxLength: 320
        emailAddress:
          type: string
          format: email
          maxLength: 320
        firstName:
          type: string
          maxLength: 100
        first_name:
          type: string
          maxLength: 100
        lastName:
          type: string
          maxLength: 100
        last_name:
          type: string
          maxLength: 100
        company:
          type: string
          maxLength: 255
        company_name:
          type: string
          maxLength: 255
        organization:
          type: string
          maxLength: 255
        title:
          type: string
          maxLength: 255
        job_title:
          type: string
          maxLength: 255
        phone:
          type: string
          maxLength: 50
        phone_number:
          type: string
          maxLength: 50
        mobilePhone:
          type: string
          maxLength: 50
        mobile_phone:
          type: string
          maxLength: 50
        mobile:
          type: string
          maxLength: 50
        city:
          type: string
          maxLength: 100
        state:
          type: string
          maxLength: 100
        region:
          type: string
          maxLength: 100
        province:
          type: string
          maxLength: 100
        postalCode:
          type: string
          maxLength: 20
        postal_code:
          type: string
          maxLength: 20
        zip:
          type: string
          maxLength: 20
        country:
          type: string
          maxLength: 100
        timezone:
          type: string
          maxLength: 50
        time_zone:
          type: string
          maxLength: 50
    OpaqueId:
      type: string
      minLength: 1
      maxLength: 128
      pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'
      description: Stable opaque identifier. Must not contain an email address or other personal data.
    UserSubject:
      type: object
      additionalProperties: false
      required: [type, id]
      properties:
        type:
          const: user
        id:
          $ref: '#/components/schemas/OpaqueId'
    EmailVerification:
      type: object
      additionalProperties: false
      required: [status, method]
      properties:
        status:
          type: string
          enum: [verified, unverified, unknown]
        verifiedAt:
          type: string
          format: date-time
          description: Required only for verified status and must not be later than the enclosing observedAt.
        method:
          type: string
          enum: [customer_account, magic_link, imported, manual]
      allOf:
        - if:
            properties:
              status:
                const: verified
          then:
            required: [verifiedAt]
          else:
            not:
              required: [verifiedAt]
    IdentifyEmail:
      type: object
      additionalProperties: false
      required: [address]
      properties:
        address:
          type: string
          format: email
          maxLength: 320
          x-max-utf8-bytes: 320
        verification:
          description: Omission preserves existing evidence for the same address. It never carries verification onto a different address. Verified status requires verifiedAt; other statuses prohibit it.
          $ref: '#/components/schemas/EmailVerification'
    Traits:
      type: object
      description: |
        A closed, versioned set of source-owned traits. Standard contact fields may project
        onto the CopyLoop contact only while this source subject owns that field; CRM or
        manually managed values are not overwritten. Use unsetTraits to remove a value.
      additionalProperties: false
      properties:
        firstName:
          type: string
          minLength: 1
          maxLength: 100
        lastName:
          type: string
          minLength: 1
          maxLength: 100
        company:
          type: string
          minLength: 1
          maxLength: 255
        title:
          type: string
          minLength: 1
          maxLength: 255
        phone:
          type: string
          minLength: 1
          maxLength: 50
        mobilePhone:
          type: string
          minLength: 1
          maxLength: 50
        city:
          type: string
          minLength: 1
          maxLength: 100
        state:
          type: string
          minLength: 1
          maxLength: 100
        postalCode:
          type: string
          minLength: 1
          maxLength: 20
        country:
          type: string
          minLength: 1
          maxLength: 100
        timezone:
          type: string
          minLength: 1
          maxLength: 50
        plan:
          type: string
          minLength: 1
          maxLength: 100
        signedUpAt:
          type: string
          format: date-time
    IdentifyRequest:
      type: object
      additionalProperties: false
      required: [observationId, subject, observedAt, email]
      properties:
        observationId:
          $ref: '#/components/schemas/OpaqueId'
        subject:
          $ref: '#/components/schemas/UserSubject'
        observedAt:
          type: string
          format: date-time
          description: Must be no more than 10 years old and no more than 10 minutes in the future at admission.
        traitSchemaVersion:
          const: 1
          default: 1
        mode:
          type: string
          enum: [patch, snapshot]
          default: patch
        state:
          type: string
          enum: [active, inactive]
          default: active
        email:
          $ref: '#/components/schemas/IdentifyEmail'
        traits:
          default: {}
          $ref: '#/components/schemas/Traits'
        unsetTraits:
          type: array
          default: []
          maxItems: 13
          uniqueItems: true
          items:
            type: string
            enum:
              [
                firstName,
                lastName,
                company,
                title,
                phone,
                mobilePhone,
                city,
                state,
                postalCode,
                country,
                timezone,
                plan,
                signedUpAt,
              ]
        addToListIds:
          type: array
          maxItems: 25
          uniqueItems: true
          description: |
            Optional add-only list destinations in the authenticated source's workspace.
            Omission or an empty array changes no list memberships. Successful production
            identification may add or restore a removed membership, but never removes an
            unlisted membership, revives an unsubscribed membership, or records consent.
          items:
            type: string
            format: uuid
      allOf:
        - if:
            properties:
              mode:
                const: snapshot
          then:
            properties:
              unsetTraits:
                maxItems: 0
    TrialStartedProperties:
      type: object
      additionalProperties: false
      properties:
        plan:
          type: string
          minLength: 1
          maxLength: 100
        trialDays:
          type: integer
          minimum: 0
          maximum: 366
    EventRequest:
      type: object
      additionalProperties: false
      required: [eventId, subject, event, schemaVersion, occurredAt, properties]
      properties:
        eventId:
          $ref: '#/components/schemas/OpaqueId'
        subject:
          $ref: '#/components/schemas/UserSubject'
        event:
          const: trial.started
        schemaVersion:
          const: 1
        occurredAt:
          type: string
          format: date-time
          description: Must be no more than 10 years old and no more than 10 minutes in the future at admission.
        properties:
          $ref: '#/components/schemas/TrialStartedProperties'
    MarketingChannelScope:
      type: object
      additionalProperties: false
      required: [type, channel]
      properties:
        type:
          const: marketing_channel
        channel:
          const: email
    ConsentContactPoint:
      type: object
      additionalProperties: false
      required: [type, address]
      properties:
        type:
          const: email
        address:
          type: string
          format: email
          maxLength: 320
          x-max-utf8-bytes: 320
    ConsentEvidence:
      type: object
      additionalProperties: false
      required: [actorType, method, collectionPoint, noticeVersion, contentRef]
      properties:
        actorType:
          type: string
          enum: [end_user, customer_operator, system]
        method:
          type: string
          enum:
            [
              explicit_checkbox,
              preference_center,
              one_click_unsubscribe,
              customer_support,
              contract,
            ]
        collectionPoint:
          type: string
          pattern: '^[A-Za-z][A-Za-z0-9._-]{0,63}$'
        noticeVersion:
          type: string
          minLength: 1
          maxLength: 100
        contentRef:
          type: string
          minLength: 1
          maxLength: 200
        region:
          type: string
          minLength: 2
          maxLength: 20
    ConsentRequest:
      type: object
      additionalProperties: false
      required: [decisionId, subject, scope, contactPoint, decision, occurredAt, evidence]
      properties:
        decisionId:
          $ref: '#/components/schemas/OpaqueId'
        subject:
          $ref: '#/components/schemas/UserSubject'
        scope:
          $ref: '#/components/schemas/MarketingChannelScope'
        contactPoint:
          $ref: '#/components/schemas/ConsentContactPoint'
        decision:
          type: string
          enum: [grant, deny, withdraw, resubscribe]
        occurredAt:
          type: string
          format: date-time
          description: Must be no more than 10 years old and no more than 10 minutes in the future at admission.
        supersedesDecisionId:
          $ref: '#/components/schemas/OpaqueId'
        evidence:
          $ref: '#/components/schemas/ConsentEvidence'
      allOf:
        - if:
            properties:
              decision:
                const: resubscribe
          then:
            required: [supersedesDecisionId]
        - if:
            properties:
              decision:
                not:
                  const: resubscribe
          then:
            not:
              required: [supersedesDecisionId]
    AcceptedResponse:
      type: object
      additionalProperties: false
      required: [receiptId, admissionDisposition, requestId, acceptedAt]
      properties:
        receiptId:
          type: string
          pattern: '^rcpt_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
        admissionDisposition:
          type: string
          enum: [new, transport_replay, domain_duplicate]
        requestId:
          type: string
          pattern: '^req_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
        acceptedAt:
          type: string
          format: date-time
    ReceiptOutcome:
      type: object
      additionalProperties: false
      required: [code, retryable]
      properties:
        code:
          type: string
          maxLength: 100
        retryable:
          type: boolean
    Receipt:
      type: object
      additionalProperties: false
      required: [receiptId, operation, status, terminal, statusVersion, acceptedAt, updatedAt]
      properties:
        receiptId:
          type: string
          pattern: '^rcpt_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
        operation:
          type: string
          enum: [contact.identify, event.record, consent.record]
        status:
          type: string
          description: Open status string. Unknown values must be preserved. Quarantined is a terminal outcome in private-preview v1; unresolved remains nonterminal.
        terminal:
          type: boolean
          description: Authoritative completion flag. A terminal quarantine requires a separate corrected or reconciled operation rather than continued polling.
        statusVersion:
          type: integer
          minimum: 1
        acceptedAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        completedAt:
          type: string
          format: date-time
        outcome:
          $ref: '#/components/schemas/ReceiptOutcome'
        pollAfterSeconds:
          type: integer
          minimum: 1
          maximum: 3600
    ErrorDetail:
      type: object
      additionalProperties: false
      required: [path, code]
      properties:
        path:
          type: string
        code:
          type: string
    Error:
      type: object
      additionalProperties: false
      required: [status, code, message, retryable, requestId]
      properties:
        status:
          type: integer
        code:
          type: string
        message:
          type: string
        retryable:
          type: boolean
        requestId:
          type: string
        details:
          type: array
          maxItems: 20
          items:
            $ref: '#/components/schemas/ErrorDetail'
    ErrorResponse:
      type: object
      additionalProperties: false
      required: [error]
      properties:
        error:
          $ref: '#/components/schemas/Error'
