openapi: 3.1.0
info:
  title: MarsX Lead Intake API
  version: 1.0.0
  summary: Contract for leads that a source sends to MarsX Dashboard, and for the read-only reconciliation API that a source hosts when it pushes.
  license:
    name: Proprietary. MarsX contract document
    identifier: LicenseRef-MarsX-Proprietary
  description: |
    Version 1.0, 4 October 2026. Partner code: a short label that MarsX issues to each source. The label is not the identity of a connection.

    This document is the reference contract for the MarsX Lead Intake API v1. A source can adopt it as the base of its own published OpenAPI 3.1 document. The guides at https://docs.marsx.media explain it step by step.

    ## Who hosts what

    | Direction | Who hosts it | Where it is described |
    |---|---|---|
    | The source sends lead events to MarsX | MarsX | `webhooks` section. Endpoint: `POST https://api.marsx.media/v1/sources/{connection}/events` |
    | MarsX reads from the source to find anything missed | The source | `paths` section. Read-only |
    | The source reads the outcome of a stored event (optional) | MarsX | `paths` section, with its own `servers` entry. Read-only. `GET https://api.marsx.media/v1/sources/{connection}/events/{webhook_id}` |
    | The source uploads a template file (fallback route) | MarsX | `paths` section, with its own `servers` entry. `PUT https://api.marsx.media/v1/sources/{connection}/files/{file_name}` |

    MarsX hosts: production `https://api.marsx.media` and sandbox `https://api-sandbox.marsx.media`. The source tells MarsX its own sandbox and production base URLs and token URLs when it is ready to connect.

    ## One connection, one brand

    Everything in this API belongs to one **connection**, and a connection belongs to exactly one client brand. `{connection}` is an opaque reference that MarsX issues for each connection and environment. Its format is `conn_` followed by 16 lower-case base32 characters. The reference in the examples, `conn_exampleexample22`, is not real. The path never carries a brand name.

    Credentials, OAuth clients, signing keys, cursors, lead identities, `webhook-id` de-duplication and privacy references are all scoped to one connection. Identical lead IDs or `webhook-id` values on two connections are independent. A credential from one connection fails on another.

    ## Standards used

    - Standard Webhooks 1.0.0 for the headers, the signature and the retry schedule: https://www.standardwebhooks.com
    - RFC 9457 problem details for errors: https://www.rfc-editor.org/rfc/rfc9457.html
    - RFC 6749 section 4.4, OAuth 2.0 client credentials, for the read-only calls: https://www.rfc-editor.org/rfc/rfc6749.html#section-4.4

    ## Field source

    The `data` object carries the fields of the MarsX Dashboard lead intake template, version 1.2, with the exact template names. The `File info` sheet does not apply to push. Each event carries `partner_lead_id` and `source_revision` instead. A `lead.new` event may leave out `source_revision` and `updated_at`: MarsX then uses 1 and the value of `created_at`. Every other event carries both. Every date-time carries an explicit UTC offset. MarsX stores the instant and shows WIB.

    ## Rules that the schemas cannot show

    - `202` means MarsX has the event safely. It does not mean the lead has changed yet. The response reports intake only: `accepted` (verified, durably stored, queued, not yet applied) or `duplicate` (this `webhook-id` was already received). A background worker applies the event afterwards. Only `202` confirms acceptance.
    - Limits. MarsX stores each event and answers within 10 seconds, then processes it from a queue, so normal bursts never need throttling. The source keeps each connection at or below 100 event requests per 10 seconds and 60 status requests per 60 seconds. MarsX may answer any request with `429`, `502`, `503` or `504`. The source treats each as a signal to slow down, waits at least the `Retry-After` value when present, and otherwise continues its normal retry schedule (the Standard Webhooks schedule).
    - None of `429`, `502`, `503`, `504`, a timeout or a dropped connection confirms acceptance. The event may already be stored, because a gateway can fail after MarsX has saved it. The source retries the same event with the same `webhook-id` and the same body, with a fresh `webhook-timestamp` and signature. MarsX de-duplicates by `webhook-id`, so a repeat is answered `202` and is not processed twice.
    - MarsX authenticates first. On the events endpoint, a missing or wrong signature, a timestamp outside the tolerance window, an unknown connection path, an inactive connection and a credential from another connection all give the same `401` response. `400`, `413` (above 32,768 bytes) and `415` are seen only by an authenticated sender. The events endpoint never returns `404`. Size limits are per route. A body above the transport cap of its route gets `413` before authentication: 10 MiB (10,485,760 bytes) for the file upload path, 1 MiB (1,048,576 bytes) for every other path, known or unknown. After authentication, an event above 32,768 bytes gets `413`, and a file above its content limits (10 MiB on disk, 100 MiB expanded, 10,000 rows, 500 characters per cell) is rejected whole. The `404` answers of the status endpoint and of the source-hosted lead fetch apply only to an authenticated caller's own connection.
    - A refused event (`400`, `413` or `415` to an authenticated sender) stores no lead data. MarsX keeps a restricted rejection record (reason, `instance` reference, connection, event type and the lead ID if it could be read) with no customer data. A refused `lead.withdraw` or `lead.delete` alerts MarsX's privacy operator.
    - `webhook-id` de-duplicates deliveries of the same event and nothing else. It is unique per event and the same on every retry.
    - A new event for the same lead (update, withdraw, delete) is always a new event with a new `webhook-id`.
    - Ordering is by `source_revision`. A `lead.new` without a revision counts as revision 1. The worker marks a lower revision of `new` or `update` as `ignored_stale_revision`. An `update` for a lead MarsX does not know is applied as the first state. Do not rely on arrival order.
    - A `withdraw` or `delete` is applied even when its `source_revision` is lower than the stored one. When MarsX stores a `lead.withdraw` or `lead.delete`, it places a pending restriction on the lead before the worker runs, so no salesperson can see or contact the lead in between. The status endpoint still reports `queued` and then `applied`. MarsX applies privacy events before other events in its queue. A `delete` after a `withdraw`, with a new `request_reference`, is applied.
    - An expired `privacy-events` cursor fails closed. MarsX keeps the connection's leads restricted for contact until recovery is proven. Starting again from the oldest item held does not prove completeness. The source must be able to supply a full list of the currently withdrawn, restricted and erased lead IDs of the connection when asked.
    - What the worker decided is read from the status endpoint: `processing_state` is `queued`, `applied`, `ignored_stale_revision`, `ignored_duplicate_content` or `quarantined`.
    - MarsX stores `request_type` unchanged in its privacy request register. In release 1 a restriction and a withdrawal set the same marker.
    - MarsX logs metadata only (connection, event type, `webhook-id`, `instance`, status and timing). It never logs field values, request bodies, signatures or tokens.
    - After a withdraw or delete, any later `new` or `update` for that lead is set aside for the privacy operator. It cannot restore personal data.
    - `Lists` sheet values (models, provinces, `source_channel`, `consent_purpose`) change by written notice with 30 days lead time. `/v1` changes that break a field, a required rule or a status code ship as `/v2` with at least 90 days of overlap.
    - Sources never send status, assignment, follow-up, test drives, sales or SPK numbers. MarsX never writes anything back to the source.
    - Never include KTP or family-card numbers, income, financing documents, full chat transcripts, or AI guesses about affordability or location.
    - All examples in this document are fictional.
  contact:
    name: Tim Teknis MarsX (MarsX Technical Team)
    email: fjrgumilang@gmail.com
tags:
  - name: Events sent by the source
    description: One lead event per request, signed with the Standard Webhooks scheme. MarsX hosts the receiving endpoint.
  - name: Reconciliation
    description: Read-only calls that MarsX makes to the source to find missed events. The source hosts these endpoints.
  - name: Event status
    description: Optional. A read-only call that the source can make to MarsX to read what the worker decided about one stored event. MarsX hosts this endpoint.
  - name: File upload
    description: Fallback route for a source that cannot push. The source uploads a file in the standard template. MarsX hosts this endpoint.
servers:
  - url: "{sourceBaseUrl}"
    description: The source-hosted reconciliation API. The source states its sandbox and production base URLs. Use the sandbox URL for testing and the production URL for live leads.
    variables:
      sourceBaseUrl:
        default: https://source.example
        description: The source's base URL for the environment. The host shown is a reserved name that never resolves.
security:
  - SourceOAuth:
      - leads:read

paths:
  /v1/changes:
    get:
      tags:
        - Reconciliation
      operationId: listChanges
      summary: List lead revisions changed since a cursor
      description: |
        Returns one item per changed lead revision, oldest first. Items carry identity and revision only. MarsX fetches the full state of any lead it is missing or holds at an older revision with `GET /v1/leads/{partner_lead_id}`.

        Rules:

        - Omit `cursor` to start at the oldest change still held. The source holds changes for at least 90 days.
        - `next_cursor` is opaque. MarsX stores it and sends it back unchanged. It is returned on every page, including an empty page, so that MarsX can continue from it later.
        - The order and the cursor must survive several changes with the same `updated_at`. A change must never appear behind a cursor that the source has already handed out. One way to do this is to order by `updated_at` and then by a unique, increasing internal sequence number that is assigned when the change is committed.
        - A cursor older than the replay window returns `410` with problem type `urn:marsx:problem:cursor-expired`. MarsX then starts again from the oldest change held, re-reads the leads it holds with `/v1/leads/{partner_lead_id}`, and shows the connection as not verified for completeness until the source has resent any lead that fell in the gap. A resend is a normal push.
        - MarsX reads this list every 15 minutes. Default page size is at least 100 and the maximum at least 500. MarsX requests `limit=500`.
        - Withdrawals and deletions are not required in this list. The privacy events call is their source of truth.
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of changes.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChangesPage"
              examples:
                twoChanges:
                  summary: Two changes, more pages follow
                  value:
                    items:
                      - partner_lead_id: SRC-TEST-0001
                        source_revision: 2
                        updated_at: "2026-10-04T10:20:00+07:00"
                      - partner_lead_id: SRC-TEST-0002
                        source_revision: 1
                        updated_at: "2026-10-04T10:20:00+07:00"
                    next_cursor: opaque-cursor-example-0002
                    has_more: true
                emptyPage:
                  summary: No new changes. Keep the cursor.
                  value:
                    items: []
                    next_cursor: opaque-cursor-example-0002
                    has_more: false
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "410":
          $ref: "#/components/responses/CursorExpired"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        5XX:
          $ref: "#/components/responses/ServerError"

  /v1/privacy-events:
    get:
      tags:
        - Reconciliation
      operationId: listPrivacyEvents
      summary: Replay every withdrawal, deletion and restriction since a cursor
      description: |
        Returns every privacy request, oldest first. This list is separate from lead revisions on purpose. A list of current leads cannot show a lead that was deleted, and a withdrawal can carry an older revision than the stored lead.

        Rules:

        - Return every withdrawal, deletion and restriction, including for lead IDs that the source no longer holds.
        - Keep events replayable for at least 90 days.
        - Omit `cursor` to start at the oldest event still held.
        - `next_cursor` is opaque, stable and returned on every page. It follows the same rules as for `/v1/changes`.
        - A cursor older than the replay window returns `410` with problem type `urn:marsx:problem:cursor-expired`. This fails closed. MarsX cannot prove that it has seen every withdrawal and deletion, so it keeps the leads of the connection restricted for contact until recovery is complete and proven. Starting again from the oldest event held does not by itself prove completeness.
        - The source states how far back this feed goes (the replay window above). The source must also be able to supply, when MarsX asks, a full list of the lead IDs of this connection that are currently withdrawn, restricted or erased, including IDs it no longer holds. The source sends it as a template file with only `withdraw` and `delete` rows, named `<partner_code>_<YYYY-MM-DD>_privacy-list.xlsx`, through `PUT /v1/sources/{connection}/files/{file_name}`. Never email or WhatsApp.
        - MarsX compares that list with its own privacy markers, applies any that are missing, and lifts the connection restriction only when recovery is proven. A deletion older than the replay window is still honoured.
        - Items carry no contact, interest or profile data.
        - MarsX reads this feed every 15 minutes.
        - Mapping to push events: `erasure` is sent as `lead.delete`. `withdraw_consent` and `restriction` are sent as `lead.withdraw`. This is the mapping.
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of privacy events.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PrivacyEventsPage"
              examples:
                twoEvents:
                  summary: One withdrawal and one erasure of a lead that the source no longer holds
                  value:
                    items:
                      - partner_lead_id: SRC-TEST-0001
                        request_type: withdraw_consent
                        request_time: "2026-10-04T14:59:30+07:00"
                        request_reference: PRIV-TEST-0001
                      - partner_lead_id: SRC-TEST-0007
                        request_type: erasure
                        request_time: "2026-10-04T15:30:00+07:00"
                        request_reference: PRIV-TEST-0002
                    next_cursor: opaque-cursor-example-0102
                    has_more: false
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "410":
          $ref: "#/components/responses/CursorExpired"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        5XX:
          $ref: "#/components/responses/ServerError"

  /v1/leads/{partner_lead_id}:
    get:
      tags:
        - Reconciliation
      operationId: getLead
      summary: Fetch the current state of one lead
      description: |
        Returns what the source would send as the latest event for this lead, in the same `data` schema as the push events.

        - For a lead that is live, the body is the full current state with `record_action` `new` or `update`.
        - For a lead with a withdrawal or restriction, the body is the withdrawal form: identity fields and privacy request fields only. It carries no contact, interest or profile data.
        - For a lead that the source has erased and no longer holds, the answer is `404`. It applies only to an authenticated caller. MarsX finds the erasure in `/v1/privacy-events`.
      parameters:
        - name: partner_lead_id
          in: path
          required: true
          description: The lead ID that the source sent in `data.partner_lead_id`. URL-encode it.
          schema:
            type: string
            minLength: 1
            examples:
              - SRC-TEST-0001
      responses:
        "200":
          description: Current state of the lead.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LeadData"
              examples:
                liveLead:
                  $ref: "#/components/examples/NewLeadData"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        5XX:
          $ref: "#/components/responses/ServerError"

  /v1/sources/{connection}/events/{webhook_id}:
    get:
      tags:
        - Event status
      operationId: getEventStatus
      summary: Read what the worker decided about one stored event
      description: |
        *Optional.* This endpoint is hosted by **MarsX**, not by the source. The `servers` entry on this operation overrides the source server. Full URL: `GET https://api.marsx.media/v1/sources/{connection}/events/{webhook_id}`.

        A `202` on the events endpoint means the event is safely stored and queued. It does not mean the event has been applied. This call returns the outcome.

        - Read-only. Returns the processing state of one event and no customer contact, interest or profile data.
        - Authentication: OAuth 2.0 client credentials (RFC 6749 section 4.4) issued by MarsX. Token URL `https://api.marsx.media/oauth/token` (sandbox: `https://api-sandbox.marsx.media/oauth/token`). One client per connection and environment, read-only scope `events:read`, tokens last 3600 seconds, client authentication HTTP Basic.
        - A client can read only the events of its own connection. A token from another connection fails.
        - `404` means MarsX has no event with this `webhook-id` for this connection. It applies only to an authenticated caller's own connection. Either it was never stored, so send it again, or the status is older than 90 days. MarsX keeps status and `webhook-id` de-duplication for 90 days.
        - Keep status requests at or below 60 per 60 seconds per connection. MarsX may answer any request with `429`, `502`, `503` or `504`. Treat each as a signal to slow down and wait at least `Retry-After` when present.
        - For a `webhook-id` that MarsX received more than once, the answer describes the first receipt.
        - For a `lead.withdraw` or `lead.delete`, the lead is already under a pending restriction from the moment MarsX stored the event, and `applied` confirms that the privacy marker is set.
      servers:
        - url: https://api.marsx.media
          description: MarsX production.
        - url: https://api-sandbox.marsx.media
          description: MarsX sandbox. Synthetic data only.
      security:
        - MarsxOAuth:
            - events:read
      parameters:
        - $ref: "#/components/parameters/Connection"
        - name: webhook_id
          in: path
          required: true
          description: The `webhook-id` header value that the source sent with the event.
          schema:
            type: string
            minLength: 1
            pattern: "^[A-Za-z0-9_-]+$"
            examples:
              - msg_test_0001
      responses:
        "200":
          description: The processing state of the event.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EventProcessingStatus"
              examples:
                applied:
                  summary: A withdrawal that has taken effect
                  value:
                    webhook_id: msg_test_0001
                    type: lead.withdraw
                    partner_lead_id: SRC-TEST-0001
                    source_revision: 3
                    processing_state: applied
                    received_at: "2026-10-04T15:00:01+07:00"
                    processed_at: "2026-10-04T15:00:03+07:00"
                queued:
                  summary: Stored and waiting for the worker
                  value:
                    webhook_id: msg_test_0002
                    type: lead.update
                    partner_lead_id: SRC-TEST-0002
                    source_revision: 2
                    processing_state: queued
                    received_at: "2026-10-04T15:05:00+07:00"
                    processed_at: null
                staleRevision:
                  summary: A lower revision, ignored
                  value:
                    webhook_id: msg_test_0003
                    type: lead.update
                    partner_lead_id: SRC-TEST-0001
                    source_revision: 1
                    processing_state: ignored_stale_revision
                    received_at: "2026-10-04T15:10:00+07:00"
                    processed_at: "2026-10-04T15:10:02+07:00"
                duplicateContent:
                  summary: Same revision and identical content, under a new webhook-id
                  value:
                    webhook_id: msg_test_0005
                    type: lead.update
                    partner_lead_id: SRC-TEST-0002
                    source_revision: 2
                    processing_state: ignored_duplicate_content
                    received_at: "2026-10-04T15:20:00+07:00"
                    processed_at: "2026-10-04T15:20:02+07:00"
                quarantined:
                  summary: An update after a withdrawal, set aside for the privacy operator
                  value:
                    webhook_id: msg_test_0004
                    type: lead.update
                    partner_lead_id: SRC-TEST-0001
                    source_revision: 5
                    processing_state: quarantined
                    reason: privacy_marker_set
                    received_at: "2026-10-04T15:15:00+07:00"
                    processed_at: "2026-10-04T15:15:02+07:00"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        5XX:
          $ref: "#/components/responses/ServerError"

  /v1/sources/{connection}/files/{file_name}:
    put:
      tags:
        - File upload
      operationId: uploadFile
      summary: Upload one template file (fallback route)
      description: |
        Hosted by **MarsX**. For a source that cannot push. The file is the MarsX Dashboard lead intake template, version 1.2 (`marsx-dashboard-lead-intake-template-v1.2.xlsx`). The fields and rules are the same as for push. Date-times in the file use `+07:00`.

        - The file name is `<partner_code>_<YYYY-MM-DD>_<file_sequence>.xlsx`, or `<partner_code>_<YYYY-MM-DD>_privacy-list.xlsx` for the full privacy list.
        - The body is the file bytes. The same three `webhook-*` headers sign the file bytes. The `webhook-id` is the file name without `.xlsx`.
        - MarsX answers `201` after durable storage. A repeat of the same file is answered `201` with `result` `duplicate` and changes nothing. MarsX emails the outcome of each file (accepted, rejected with the reason, or a gap in the sequence) to the source's technical contact.
        - Transport cap of this route: 10 MiB (10,485,760 bytes), answered `413` before authentication. This is the file route's own cap. Other paths have a 1 MiB cap.
        - Content limits after authentication: 10 MiB on disk, 100 MiB expanded, 10,000 rows, 500 characters per cell. A file over a limit, or with a macro, a formula or an external link, is rejected whole. MarsX processes the files of each connection separately.
        - Authentication is the same as for events, with the same generic `401`. SFTP is not offered. Never email or WhatsApp.
        - This call is route (b), a signed upload by the source's system. Route (c) is the same file uploaded by named staff of the source through a MarsX upload account. That account is limited to the source's own connection, shows no lead data, needs an authenticator code at every sign-in and is granted by MarsX. Staff upload the file exactly as exported and never edit it. Both routes follow the same file name, limit and processing rules.
      servers:
        - url: https://api.marsx.media
          description: MarsX production.
        - url: https://api-sandbox.marsx.media
          description: MarsX sandbox. Synthetic data only.
      security: []
      parameters:
        - $ref: "#/components/parameters/Connection"
        - name: file_name
          in: path
          required: true
          description: The file name.
          schema:
            type: string
            pattern: "^[A-Z]+_[0-9]{4}-[0-9]{2}-[0-9]{2}_([0-9]{6}|privacy-list)\\.xlsx$"
            examples:
              - EXAMPLE_2026-10-01_000123.xlsx
        - $ref: "#/components/parameters/WebhookId"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookSignature"
      requestBody:
        required: true
        content:
          application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
            schema:
              type: string
              contentMediaType: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
      responses:
        "201":
          $ref: "#/components/responses/FileStored"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/UnauthorizedIntake"
        "413":
          description: The body is above 10 MiB (10,485,760 bytes), the transport cap of this route, answered before authentication. Do not retry the same request.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        5XX:
          $ref: "#/components/responses/ServerError"

webhooks:
  leadNew:
    post:
      tags:
        - Events sent by the source
      operationId: receiveLeadNew
      summary: "lead.new: a customer made a new enquiry"
      description: |
        Sent to `POST https://api.marsx.media/v1/sources/{connection}/events` (sandbox: `https://api-sandbox.marsx.media`). `{connection}` is the opaque reference that MarsX issues for the connection and environment.

        `data` carries the full current state of the lead. `data.record_action` must be `new`. Required fields are listed in `NewLeadData`: `partner_lead_id`, `record_action`, `created_at`, `customer_name`, at least one of `phone` or `email`, `source_channel`, `contact_consent` (`yes`) and `consent_time`. A blank `source_revision` means 1 and a blank `updated_at` means `created_at`. A lead without `model_interest` or `customer_city` is accepted and marked incomplete. A lead with no model, no city and no preferred dealer goes to MarsX triage instead of being set aside. Section H (privacy request fields) must be absent.

        Worked sequence for one lead: this `lead.new` (revision 1), then a `lead.update` (revision 2, with the time of the change), then a `lead.withdraw` (revision 3, with its own time).
      security: []
      parameters:
        - $ref: "#/components/parameters/WebhookId"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookSignature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LeadNewEvent"
            examples:
              fullLead:
                summary: A new lead (fictional)
                value:
                  type: lead.new
                  timestamp: "2026-10-01T09:15:00+07:00"
                  data:
                    partner_lead_id: SRC-TEST-0001
                    partner_customer_id: SRC-CUST-TEST-0001
                    source_revision: 1
                    record_action: new
                    created_at: "2026-10-01T09:15:00+07:00"
                    updated_at: "2026-10-01T09:15:00+07:00"
                    customer_name: Contoh Pelanggan
                    phone: "+6280000000001"
                    email: contoh.pelanggan@contoh.example
                    model_interest: Model A
                    purchase_timeframe: 3 bulan ke depan
                    test_drive_requested: "N"
                    enquiry_notes: Ingin meminta penawaran untuk Model A.
                    customer_province: Jawa Barat
                    customer_city: Kota Bandung
                    source_channel: chat
                    campaign: campaign-test
                    utm_source: example
                    utm_medium: chat
                    utm_campaign: campaign-test
                    session_id: SESSION-TEST-0001
                    consent_reference: CONSENT-TEST-0001
                    contact_consent: "yes"
                    consent_time: "2026-10-01T09:14:40+07:00"
                    notice_version: NOTICE-TEST-1
                    consent_purpose: sales_follow_up
                    ai_intent_label: ingin_penawaran
                    ai_summary: Pelanggan meminta penawaran Model A.
              minimalLead:
                summary: The smallest valid new lead. No revision and no time of change, so MarsX uses 1 and the enquiry time (fictional)
                value:
                  type: lead.new
                  timestamp: "2026-10-01T10:30:00+07:00"
                  data:
                    partner_lead_id: SRC-TEST-0004
                    record_action: new
                    created_at: "2026-10-01T10:29:30+07:00"
                    customer_name: Contoh Pelanggan Tiga
                    phone: "+6280000000003"
                    source_channel: website_form
                    contact_consent: "yes"
                    consent_time: "2026-10-01T10:29:20+07:00"
              metaLeadForm:
                summary: A new lead from a Meta lead form, with a confirmed phone (fictional)
                value:
                  type: lead.new
                  timestamp: "2026-10-01T10:05:00+07:00"
                  data:
                    partner_lead_id: META-TEST-0003
                    source_revision: 1
                    record_action: new
                    created_at: "2026-10-01T10:04:30+07:00"
                    updated_at: "2026-10-01T10:05:00+07:00"
                    customer_name: Contoh Pelanggan Dua
                    phone: "+6280000000002"
                    phone_verified: true
                    model_interest: Model A
                    purchase_timeframe: Bulan ini
                    customer_province: Jawa Barat
                    customer_city: Kota Bandung
                    source_channel: meta_lead_form
                    campaign: campaign-test
                    ad_id: AD-TEST-0001
                    ad_creative_id: CREATIVE-TEST-0001
                    consent_reference: CONSENT-TEST-0003
                    contact_consent: "yes"
                    consent_time: "2026-10-01T10:04:20+07:00"
                    notice_version: NOTICE-TEST-1
                    consent_purpose: sales_follow_up
      responses:
        "202":
          $ref: "#/components/responses/EventStored"
        "400":
          $ref: "#/components/responses/InvalidEvent"
        "401":
          $ref: "#/components/responses/UnauthorizedIntake"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        5XX:
          $ref: "#/components/responses/ServerError"

  leadUpdate:
    post:
      tags:
        - Events sent by the source
      operationId: receiveLeadUpdate
      summary: "lead.update: a lead changed"
      description: |
        Sent to `POST https://api.marsx.media/v1/sources/{connection}/events` (sandbox: `https://api-sandbox.marsx.media`). `{connection}` is the opaque reference that MarsX issues for the connection and environment.

        `data` carries the full current state, not only the changed fields. A field that is absent means the field is empty. `source_revision` and `updated_at` are required and `source_revision` must be higher than the last revision sent for this lead. `data.record_action` must be `update`.
      security: []
      parameters:
        - $ref: "#/components/parameters/WebhookId"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookSignature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LeadUpdateEvent"
            examples:
              fullLead:
                summary: An updated lead (fictional)
                value:
                  type: lead.update
                  timestamp: "2026-10-01T11:40:00+07:00"
                  data:
                    partner_lead_id: SRC-TEST-0001
                    source_revision: 2
                    record_action: update
                    created_at: "2026-10-01T09:15:00+07:00"
                    updated_at: "2026-10-01T11:40:00+07:00"
                    customer_name: Contoh Pelanggan
                    phone: "+6280000000001"
                    email: contoh.pelanggan@contoh.example
                    model_interest: Model B
                    purchase_timeframe: 1 bulan ke depan
                    test_drive_requested: "Y"
                    customer_province: Jawa Barat
                    customer_city: Kota Bandung
                    source_channel: chat
                    consent_reference: CONSENT-TEST-0001
                    contact_consent: "yes"
                    consent_time: "2026-10-01T09:14:40+07:00"
                    notice_version: NOTICE-TEST-1
                    consent_purpose: sales_follow_up
      responses:
        "202":
          $ref: "#/components/responses/EventStored"
        "400":
          $ref: "#/components/responses/InvalidEvent"
        "401":
          $ref: "#/components/responses/UnauthorizedIntake"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        5XX:
          $ref: "#/components/responses/ServerError"

  leadWithdraw:
    post:
      tags:
        - Events sent by the source
      operationId: receiveLeadWithdraw
      summary: "lead.withdraw: the customer withdrew consent or asked for restriction"
      description: |
        Sent to `POST https://api.marsx.media/v1/sources/{connection}/events` (sandbox: `https://api-sandbox.marsx.media`). `{connection}` is the opaque reference that MarsX issues for the connection and environment.

        Send this as soon as the customer asks. Never batch it.

        `data` carries identity fields (section A) and privacy request fields (section H) only. No customer contact, interest, location, attribution, consent or AI fields. `data.record_action` must be `withdraw`. `request_type` is `withdraw_consent` or `restriction`.

        MarsX applies a withdrawal even when its `source_revision` is lower than the stored revision. When MarsX stores the event, it places a pending restriction on the lead before the worker runs, so no salesperson can see or contact the lead in between. A `202` does not prove that the withdrawal has taken effect. Read `processing_state` from the status endpoint. `request_type` is stored unchanged in MarsX's privacy request register. In release 1 a restriction and a withdrawal set the same marker.
      security: []
      parameters:
        - $ref: "#/components/parameters/WebhookId"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookSignature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LeadWithdrawEvent"
            examples:
              withdrawal:
                summary: A consent withdrawal (fictional). This is the body of the signing test vector in the guides.
                value:
                  type: lead.withdraw
                  timestamp: "2026-10-04T15:00:00+07:00"
                  data:
                    partner_lead_id: SRC-TEST-0001
                    source_revision: 3
                    record_action: withdraw
                    created_at: "2026-10-01T09:15:00+07:00"
                    updated_at: "2026-10-04T15:00:00+07:00"
                    request_type: withdraw_consent
                    request_time: "2026-10-04T14:59:30+07:00"
                    request_reference: PRIV-TEST-0001
      responses:
        "202":
          $ref: "#/components/responses/EventStored"
        "400":
          $ref: "#/components/responses/InvalidEvent"
        "401":
          $ref: "#/components/responses/UnauthorizedIntake"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        5XX:
          $ref: "#/components/responses/ServerError"

  leadDelete:
    post:
      tags:
        - Events sent by the source
      operationId: receiveLeadDelete
      summary: "lead.delete: the customer asked for erasure"
      description: |
        Sent to `POST https://api.marsx.media/v1/sources/{connection}/events` (sandbox: `https://api-sandbox.marsx.media`). `{connection}` is the opaque reference that MarsX issues for the connection and environment.

        Send this as soon as the customer asks. Never batch it.

        `data` carries identity fields (section A) and privacy request fields (section H) only. `data.record_action` must be `delete`. `request_type` must be `erasure`.

        MarsX applies a deletion even when its `source_revision` is lower than the stored revision. When MarsX stores the event, it places a pending restriction on the lead before the worker runs, so no salesperson can see or contact the lead in between. A `202` does not prove that the deletion has taken effect. Read `processing_state` from the status endpoint. A `delete` after a `withdraw` of the same lead, with a new `request_reference`, is applied. After erasure MarsX keeps minimised suppression data only: the lead ID, the marker and the request details.
      security: []
      parameters:
        - $ref: "#/components/parameters/WebhookId"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookSignature"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LeadDeleteEvent"
            examples:
              erasure:
                summary: An erasure request (fictional)
                value:
                  type: lead.delete
                  timestamp: "2026-10-04T15:30:00+07:00"
                  data:
                    partner_lead_id: SRC-TEST-0007
                    source_revision: 4
                    record_action: delete
                    created_at: "2026-09-28T16:05:00+07:00"
                    updated_at: "2026-10-04T15:30:00+07:00"
                    request_type: erasure
                    request_time: "2026-10-04T15:29:10+07:00"
                    request_reference: PRIV-TEST-0002
      responses:
        "202":
          $ref: "#/components/responses/EventStored"
        "400":
          $ref: "#/components/responses/InvalidEvent"
        "401":
          $ref: "#/components/responses/UnauthorizedIntake"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        5XX:
          $ref: "#/components/responses/ServerError"

components:
  securitySchemes:
    SourceOAuth:
      type: oauth2
      description: |
        OAuth 2.0 client credentials (RFC 6749 section 4.4) for the read-only reconciliation calls. The source hosts the token endpoint.

        - The source issues one client per connection and environment to MarsX, through the one-time secure key exchange that MarsX arranges. A token from one connection fails on another.
        - The scope allows reading only. The source names it. The examples use `leads:read`.
        - Tokens are short-lived. `expires_in` is between 300 and 86,400 seconds.
        - Client authentication is HTTP Basic (`client_secret_basic`). The token URL uses HTTPS.
        - MarsX accepts a static key with an IP allow-list only as a recorded exception.
      flows:
        clientCredentials:
          tokenUrl: https://source.example/oauth/token
          scopes:
            "leads:read": Read lead changes, privacy events and single leads. No write access.
    MarsxOAuth:
      type: oauth2
      description: |
        OAuth 2.0 client credentials (RFC 6749 section 4.4) for the optional, read-only event status call. MarsX is the authorization server. The sandbox token URL is `https://api-sandbox.marsx.media/oauth/token`.

        - MarsX issues one client per connection and environment to the source, through the one-time secure key exchange that MarsX arranges. A token from one connection fails on another.
        - The scope grants read access to the status of the source's own events only: `events:read`.
        - Tokens last 3600 seconds. Client authentication is HTTP Basic.
      flows:
        clientCredentials:
          tokenUrl: https://api.marsx.media/oauth/token
          scopes:
            "events:read": Read the processing state of the source's own events. No write access.

  parameters:
    Connection:
      name: connection
      in: path
      required: true
      description: The opaque connection reference that MarsX issues for each connection and environment, with the keys. `conn_` followed by 16 lower-case base32 characters. A connection belongs to exactly one client brand. The path never carries a brand name. A credential from one connection fails on another.
      schema:
        type: string
        pattern: "^conn_[a-z2-7]{16}$"
        examples:
          - conn_exampleexample22
    WebhookId:
      name: webhook-id
      in: header
      required: true
      description: Unique ID of the event. The same value on every retry of the same event. The source's own event UUID works. It must not contain a full stop. It de-duplicates deliveries within one connection and nothing else.
      schema:
        type: string
        pattern: "^[A-Za-z0-9_-]{1,128}$"
        examples:
          - msg_test_0001
    WebhookTimestamp:
      name: webhook-timestamp
      in: header
      required: true
      description: "Time of this delivery attempt, as whole seconds since the Unix epoch. It changes on every retry, and the signature changes with it. MarsX rejects a timestamp more than 300 seconds from its own clock, in either direction."
      schema:
        type: string
        pattern: "^[0-9]+$"
        examples:
          - "1791100800"
    WebhookSignature:
      name: webhook-signature
      in: header
      required: true
      description: |
        One or more signatures, separated by a space. Each signature is a version tag, a comma and base64 text.

        - `v1,<base64>` is HMAC-SHA256 over `webhook-id.webhook-timestamp.raw-body`.
        - `v1a,<base64>` is ed25519 over the same string. This is the preferred scheme, because MarsX then holds only the source's public key.
        - During a key rotation, send two signatures, one for the old key and one for the new key.
      schema:
        type: string
        minLength: 4
        examples:
          - "v1,SGVsbG8gV29ybGQ="
    Cursor:
      name: cursor
      in: query
      required: false
      description: Opaque cursor from the previous page's `next_cursor`. Omit it to start at the oldest item held.
      schema:
        type: string
        minLength: 1
    Limit:
      name: limit
      in: query
      required: false
      description: "Maximum number of items in the page. The source's default is at least 100 and its maximum at least 500. MarsX requests 500 and accepts fewer."
      schema:
        type: integer
        minimum: 1

  headers:
    RetryAfter:
      description: How long to wait before the next attempt. Whole seconds, or an HTTP date.
      schema:
        type: string
        examples:
          - "120"

  responses:
    EventStored:
      description: |
        The event passed verification and is durably stored. MarsX sends this answer only after storage. It is queued for processing and is not yet applied. A `202` does not prove that a withdrawal or deletion has taken effect. Read the outcome from the status endpoint. A `duplicate` result means MarsX already received this `webhook-id`. Retrying an event is always safe. Only `202` confirms acceptance.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/IntakeResult"
          examples:
            accepted:
              summary: Event verified, stored and queued. Not yet applied.
              value:
                result: accepted
                webhook_id: msg_test_0001
                request_id: req_example_0001
            duplicate:
              summary: This webhook-id was already received, for example when a 202 was lost and the source retried. Nothing new was stored.
              value:
                result: duplicate
                webhook_id: msg_test_0001
                request_id: req_example_0002
    InvalidEvent:
      description: |
        The event is not valid. Fix it and do not retry the same request. The event stays owed: fix the cause and send it again. The sender is authenticated, so MarsX stores no lead data but keeps a restricted rejection record: the reason, the `instance` reference, the connection, the event type, and the lead ID if it could be read. The record holds no customer data. A refused `lead.withdraw` or `lead.delete` also alerts MarsX's privacy operator.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetails"
          examples:
            invalidField:
              value:
                type: urn:marsx:problem:invalid-event
                title: The event does not match the lead contract
                status: 400
                detail: One or more fields are missing or not valid for this action.
                instance: req_example_0005
                errors:
                  - pointer: /data/contact_consent
                    message: Required for lead.new and lead.update. The only accepted value is yes.
    BadRequest:
      description: The query or the path is not valid, for example `limit` below 1 or above the maximum, or a file name that does not match the pattern. Fix the call. Do not retry the same request.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetails"
          examples:
            badLimit:
              value:
                type: urn:marsx:problem:invalid-query
                title: The query is not valid
                status: 400
                detail: The limit is above the maximum.
                instance: req_example_0008
    FileStored:
      description: The file is durably stored. It is queued for processing. A repeat of the same file is answered with `duplicate` and changes nothing.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/FileResult"
          examples:
            stored:
              value:
                result: stored
                file_name: EXAMPLE_2026-10-01_000123.xlsx
                request_id: req_example_0009
    UnauthorizedIntake:
      description: |
        Authentication of the event failed. The answer is deliberately generic and identical for every cause, so that a caller who is not authenticated cannot learn which connections MarsX has. These cases give exactly the same response, with the same status, headers, problem type, title, detail and body shape. Only the `Date` header and the `instance` (request ID) differ.

        - The signature is missing or wrong.
        - The `webhook-timestamp` is missing, not a number or outside the tolerance window.
        - The connection path is unknown.
        - The connection is not active in this environment.
        - The signature was made with the key of another connection.

        MarsX authenticates first. A request that fails authentication gets this answer before any content type, body size (below the transport cap of its route), JSON or field check. So `400`, `413` and `415` are seen only by an authenticated sender. Do not retry the same request. Check the key, the clock, the URL and the environment. Then send the event again with a fresh timestamp and a fresh signature. MarsX can tell the exact cause from the `instance` reference in its own logs.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetails"
          examples:
            authenticationFailed:
              summary: The one answer for every authentication failure on the events endpoint
              value:
                type: urn:marsx:problem:unauthorized
                title: Authentication failed
                status: 401
                detail: The request could not be authenticated. Check the key, the clock, the URL and the environment.
                instance: req_example_0006
    Unauthorized:
      description: |
        The OAuth access token is missing, expired or invalid. Do not retry the same request. Request a new token and try again.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetails"
          examples:
            badToken:
              value:
                type: urn:marsx:problem:invalid-token
                title: The access token is missing, expired or invalid
                status: 401
                instance: req_example_0007
    Forbidden:
      description: The token is valid but does not carry the read scope. Fix the client configuration. Do not retry.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetails"
    NotFound:
      description: |
        Returned only to an authenticated caller, and only for that caller's own connection or data. For the status endpoint: MarsX has no event with this `webhook-id` for this connection. For the reconciliation API: the lead does not exist, for example because it was erased. Do not retry. The events endpoint never returns `404`: see `UnauthorizedIntake`.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetails"
    PayloadTooLarge:
      description: "The body is above a size limit. For an event, an authenticated body above 32,768 bytes gets this answer. Independently, a body above the transport cap of its route (1 MiB, or 10 MiB for the file upload path) gets this answer before authentication, the same for a known and an unknown path. Do not retry the same request."
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetails"
    UnsupportedMediaType:
      description: The `Content-Type` is not `application/json`. Media-type parameters such as `charset=utf-8` are accepted and ignored. The body must be UTF-8 without a byte-order mark. Do not retry the same request.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetails"
    CursorExpired:
      description: |
        The cursor is older than the replay window. On `privacy-events` this fails closed: MarsX keeps the leads of the connection restricted for contact until recovery is complete and proven, and starting again from the oldest event held does not by itself prove completeness. The source must be able to supply a full list of the currently withdrawn, restricted and erased lead IDs when asked. On `changes`, MarsX starts again from the oldest change held and shows the connection as not verified for completeness until any lead that fell in the gap is resent.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetails"
          examples:
            expired:
              value:
                type: urn:marsx:problem:cursor-expired
                title: The cursor is outside the replay window
                status: 410
    TooManyRequests:
      description: MarsX or the source may answer any request with `429`, including one inside the sending budget. Wait at least the `Retry-After` value, then retry on the normal schedule. It never confirms acceptance of an event.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetails"
    ServerError:
      description: A temporary fault (`5xx`, including `502`, `503` and `504`) or a timeout. None of these confirms acceptance, and the event may already be stored. Slow down, wait at least `Retry-After` when present, and retry the same event with the same `webhook-id` and body, with a fresh timestamp and signature, on the Standard Webhooks schedule. A repeat is answered `202` and is not processed twice.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetails"

  examples:
    NewLeadData:
      summary: Full state of a new lead (fictional)
      value:
        partner_lead_id: SRC-TEST-0001
        partner_customer_id: SRC-CUST-TEST-0001
        source_revision: 1
        record_action: new
        created_at: "2026-10-01T09:15:00+07:00"
        updated_at: "2026-10-01T09:15:00+07:00"
        customer_name: Contoh Pelanggan
        phone: "+6280000000001"
        email: contoh.pelanggan@contoh.example
        model_interest: Model A
        purchase_timeframe: 3 bulan ke depan
        test_drive_requested: "N"
        enquiry_notes: Ingin meminta penawaran untuk Model A.
        customer_province: Jawa Barat
        customer_city: Kota Bandung
        source_channel: chat
        campaign: campaign-test
        utm_source: example
        utm_medium: chat
        utm_campaign: campaign-test
        session_id: SESSION-TEST-0001
        consent_reference: CONSENT-TEST-0001
        contact_consent: "yes"
        consent_time: "2026-10-01T09:14:40+07:00"
        notice_version: NOTICE-TEST-1
        consent_purpose: sales_follow_up
        ai_intent_label: ingin_penawaran
        ai_summary: Pelanggan meminta penawaran Model A.

  schemas:
    ProblemDetails:
      description: Problem details for HTTP APIs (RFC 9457). Never put personal data in `detail`.
      type: object
      required:
        - type
        - title
        - status
      properties:
        type:
          type: string
          description: "A URI that identifies the problem type. MarsX uses these names: `urn:marsx:problem:unauthorized`, `invalid-event`, `payload-too-large`, `unsupported-media-type`, `rate-limited`, `server-error`, `invalid-token`, `forbidden`, `not-found`, `invalid-query` and `cursor-expired`. The source may use its own URIs on the endpoints it hosts. MarsX acts on the status code, not on the URI. Treat an unknown type as information only."
        title:
          type: string
        status:
          type: integer
        detail:
          type: string
        instance:
          type: string
          description: A correlation reference for this request. Quote it when you contact MarsX.
        errors:
          type: array
          description: Field-level problems. Present on `invalid-event`.
          items:
            type: object
            required:
              - pointer
              - message
            properties:
              pointer:
                type: string
                description: JSON Pointer to the field, for example `/data/customer_city`.
              message:
                type: string
      additionalProperties: true

    IntakeResult:
      description: |
        The answer to one lead event. It reports intake only. `accepted` means the event passed verification and is durably stored and queued. It does not mean the event has been applied. What the worker does with the event is read from `GET /v1/sources/{connection}/events/{webhook_id}`.
      type: object
      required:
        - result
        - webhook_id
        - request_id
      properties:
        result:
          type: string
          enum:
            - accepted
            - duplicate
          description: |
            - `accepted`: verified, durably stored and queued for processing. Not yet applied.
            - `duplicate`: MarsX has already received this `webhook-id`. Nothing new was stored.
        webhook_id:
          type: string
          description: The `webhook-id` of the request.
        request_id:
          type: string
          description: A MarsX reference for support.

    EventProcessingStatus:
      description: |
        What the MarsX worker decided about one stored event. Optional endpoint. It carries no customer contact, interest or profile data.
      type: object
      required:
        - webhook_id
        - type
        - partner_lead_id
        - source_revision
        - processing_state
        - received_at
        - processed_at
      properties:
        webhook_id:
          type: string
        type:
          type: string
          enum:
            - lead.new
            - lead.update
            - lead.withdraw
            - lead.delete
        partner_lead_id:
          $ref: "#/components/schemas/PartnerLeadId"
        source_revision:
          $ref: "#/components/schemas/SourceRevision"
        processing_state:
          type: string
          enum:
            - queued
            - applied
            - ignored_stale_revision
            - ignored_duplicate_content
            - quarantined
          description: |
            - `queued`: stored and waiting for the worker. `processed_at` is `null`.
            - `applied`: the worker applied the event. For `new` and `update` the lead holds this state. For `withdraw` and `delete` the lead is restricted, even when the event carried an older revision.
            - `ignored_stale_revision`: a `new` or `update` with a lower revision than the stored one. Logged and ignored.
            - `ignored_duplicate_content`: the same revision with identical content, or an identical repeat of a privacy request. Nothing changed.
            - `quarantined`: stored but not applied. `reason` says why.
        reason:
          type: string
          enum:
            - revision_conflict
            - privacy_marker_set
            - privacy_request_conflict
          description: |
            Present when `processing_state` is `quarantined`, and only then. Treat an unknown value as informational.

            - `revision_conflict`: the same `source_revision` arrived with different content.
            - `privacy_marker_set`: a `new` or `update` arrived for a lead that has a withdrawal or deletion marker.
            - `privacy_request_conflict`: a repeat of a privacy request carried different content.
        received_at:
          type: string
          format: date-time
          description: When MarsX stored the event.
        processed_at:
          type:
            - string
            - "null"
          format: date-time
          description: When the worker finished with the event. `null` while `queued`.
      if:
        required:
          - processing_state
        properties:
          processing_state:
            const: quarantined
      then:
        required:
          - reason
        properties:
          webhook_id: {}
          type: {}
          partner_lead_id: {}
          source_revision: {}
          processing_state: {}
          reason: {}
          received_at: {}
          processed_at: {}
      else:
        properties:
          webhook_id: {}
          type: {}
          partner_lead_id: {}
          source_revision: {}
          processing_state: {}
          reason: false
          received_at: {}
          processed_at: {}


    FileResult:
      description: The answer to one uploaded file.
      type: object
      required:
        - result
        - file_name
        - request_id
      properties:
        result:
          type: string
          enum:
            - stored
            - duplicate
        file_name:
          type: string
        request_id:
          type: string
          description: A MarsX reference for support.

    ChangesPage:
      type: object
      required:
        - items
        - next_cursor
        - has_more
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/ChangeItem"
        next_cursor:
          type: string
          minLength: 1
          description: Opaque and stable. Returned on every page, including an empty one.
        has_more:
          type: boolean
          description: True when another page can be read now with `next_cursor`.
    ChangeItem:
      type: object
      required:
        - partner_lead_id
        - source_revision
        - updated_at
      properties:
        partner_lead_id:
          $ref: "#/components/schemas/PartnerLeadId"
        source_revision:
          $ref: "#/components/schemas/SourceRevision"
        updated_at:
          $ref: "#/components/schemas/DateTimeWithOffset"

    PrivacyEventsPage:
      type: object
      required:
        - items
        - next_cursor
        - has_more
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/PrivacyEventItem"
        next_cursor:
          type: string
          minLength: 1
          description: Opaque and stable. Returned on every page, including an empty one.
        has_more:
          type: boolean
    PrivacyEventItem:
      type: object
      required:
        - partner_lead_id
        - request_type
        - request_time
        - request_reference
      properties:
        partner_lead_id:
          $ref: "#/components/schemas/PartnerLeadId"
        request_type:
          $ref: "#/components/schemas/RequestType"
        request_time:
          $ref: "#/components/schemas/RequestTime"
        request_reference:
          $ref: "#/components/schemas/RequestReference"

    LeadNewEvent:
      description: Event `lead.new`. Full state of a new lead.
      type: object
      required:
        - type
        - timestamp
        - data
      properties:
        type:
          const: lead.new
        timestamp:
          $ref: "#/components/schemas/EventTimestamp"
        data:
          $ref: "#/components/schemas/NewLeadData"
    LeadUpdateEvent:
      description: Event `lead.update`. Full current state of a lead that changed.
      type: object
      required:
        - type
        - timestamp
        - data
      properties:
        type:
          const: lead.update
        timestamp:
          $ref: "#/components/schemas/EventTimestamp"
        data:
          $ref: "#/components/schemas/UpdateLeadData"
    LeadWithdrawEvent:
      description: Event `lead.withdraw`. Withdrawal of consent or restriction of processing.
      type: object
      required:
        - type
        - timestamp
        - data
      properties:
        type:
          const: lead.withdraw
        timestamp:
          $ref: "#/components/schemas/EventTimestamp"
        data:
          $ref: "#/components/schemas/WithdrawLeadData"
    LeadDeleteEvent:
      description: Event `lead.delete`. Erasure request.
      type: object
      required:
        - type
        - timestamp
        - data
      properties:
        type:
          const: lead.delete
        timestamp:
          $ref: "#/components/schemas/EventTimestamp"
        data:
          $ref: "#/components/schemas/DeleteLeadData"

    EventTimestamp:
      type: string
      format: date-time
      description: When the event occurred. Not necessarily when this attempt was sent. ISO 8601.
      examples:
        - "2026-10-01T09:15:00+07:00"

    LeadData:
      description: |
        The shared `data` schema, built from the fields of the MarsX Dashboard lead intake template, version 1.2. One of four shapes, chosen by `record_action`. The push events, and the answer of `GET /v1/leads/{partner_lead_id}`, use this schema.

        | `record_action` | Content |
        |---|---|
        | `new` | Full current state. Sections A to F, with section G optional. Section H must be absent |
        | `update` | Same as `new` |
        | `withdraw` | Section A and section H only |
        | `delete` | Section A and section H only |
      oneOf:
        - $ref: "#/components/schemas/NewLeadData"
        - $ref: "#/components/schemas/UpdateLeadData"
        - $ref: "#/components/schemas/WithdrawLeadData"
        - $ref: "#/components/schemas/DeleteLeadData"
      discriminator:
        propertyName: record_action
        mapping:
          new: "#/components/schemas/NewLeadData"
          update: "#/components/schemas/UpdateLeadData"
          withdraw: "#/components/schemas/WithdrawLeadData"
          delete: "#/components/schemas/DeleteLeadData"

    NewLeadData:
      description: Full current state of a new lead. `record_action` is `new`. `source_revision` and `updated_at` may be left out. A blank `source_revision` means 1 and a blank `updated_at` means the value of `created_at`.
      allOf:
        - $ref: "#/components/schemas/LeadStateFields"
        - type: object
          properties:
            record_action:
              const: new
      unevaluatedProperties: false
    UpdateLeadData:
      description: Full current state of a changed lead. `record_action` is `update`. A field that is absent means the field is empty.
      allOf:
        - $ref: "#/components/schemas/LeadStateFields"
        - $ref: "#/components/schemas/RevisionFields"
        - type: object
          properties:
            record_action:
              const: update
      unevaluatedProperties: false
    WithdrawLeadData:
      description: Withdrawal of consent or restriction. Section A and section H only. `record_action` is `withdraw`.
      allOf:
        - $ref: "#/components/schemas/IdentityFields"
        - $ref: "#/components/schemas/RevisionFields"
        - $ref: "#/components/schemas/PrivacyRequestFields"
        - type: object
          required:
            - request_type
            - request_time
            - request_reference
          properties:
            request_time: {}
            request_reference: {}
            record_action:
              const: withdraw
            request_type:
              enum:
                - withdraw_consent
                - restriction
      unevaluatedProperties: false
    DeleteLeadData:
      description: Erasure request. Section A and section H only. `record_action` is `delete`.
      allOf:
        - $ref: "#/components/schemas/IdentityFields"
        - $ref: "#/components/schemas/RevisionFields"
        - $ref: "#/components/schemas/PrivacyRequestFields"
        - type: object
          required:
            - request_type
            - request_time
            - request_reference
          properties:
            request_time: {}
            request_reference: {}
            record_action:
              const: delete
            request_type:
              const: erasure
      unevaluatedProperties: false

    LeadStateFields:
      description: Sections A to G, with the fields required for `new` and `update`. At least one of `phone` or `email` is required. `model_interest`, `customer_city`, `consent_reference`, `notice_version` and `consent_purpose` are not required.
      allOf:
        - $ref: "#/components/schemas/IdentityFields"
        - $ref: "#/components/schemas/CustomerFields"
        - $ref: "#/components/schemas/InterestFields"
        - $ref: "#/components/schemas/LocationFields"
        - $ref: "#/components/schemas/AttributionFields"
        - $ref: "#/components/schemas/ConsentFields"
        - $ref: "#/components/schemas/AiFields"
        - type: object
          description: Required fields for `new` and `update`. The property definitions are in the sections above.
          required:
            - customer_name
            - source_channel
            - contact_consent
            - consent_time
          properties:
            customer_name: {}
            phone: {}
            email: {}
            source_channel: {}
            contact_consent: {}
            consent_time: {}
          anyOf:
            - required:
                - phone
              properties:
                phone: {}
            - required:
                - email
              properties:
                email: {}

    IdentityFields:
      description: Section A. Identity and control. `partner_lead_id`, `record_action` and `created_at` are required on every event. `source_revision` and `updated_at` are required on every event except `lead.new`, where they may be left out (see `NewLeadData` and `RevisionFields`).
      type: object
      required:
        - partner_lead_id
        - record_action
        - created_at
      properties:
        partner_lead_id:
          $ref: "#/components/schemas/PartnerLeadId"
        partner_customer_id:
          type: string
          minLength: 1
          description: Optional. The source's own customer or account ID. Links leads from the same person within this source only.
        source_revision:
          $ref: "#/components/schemas/SourceRevision"
        record_action:
          type: string
          enum:
            - new
            - update
            - withdraw
            - delete
          description: Must match the event `type` (`lead.new` is `new`, and so on).
        created_at:
          $ref: "#/components/schemas/DateTimeWithOffset"
        updated_at:
          $ref: "#/components/schemas/DateTimeWithOffset"
    RevisionFields:
      description: Required on `update`, `withdraw` and `delete`. The revision number and the time of the change. A `withdraw` or `delete` is applied even when its revision is older than the stored one, but it must carry its own revision and its own time.
      type: object
      required:
        - source_revision
        - updated_at
      properties:
        source_revision: {}
        updated_at: {}
    CustomerFields:
      description: Section B. Customer.
      type: object
      properties:
        customer_name:
          type: string
          minLength: 1
          examples:
            - Contoh Pelanggan
        phone:
          type: string
          pattern: "^\\+628[0-9]+$"
          description: Text starting with `+628`. At least one of `phone` or `email` is required for `new` and `update`.
          examples:
            - "+6280000000001"
        email:
          type: string
          format: email
          description: An email address (RFC 5322 mailbox). At least one of `phone` or `email` is required for `new` and `update`.
          examples:
            - contoh.pelanggan@contoh.example
        phone_verified:
          type:
            - boolean
            - "null"
          description: Optional. Whether the customer confirmed `phone`, for example by a one-time code from the ad platform. `true` or `false`. `null`, or leaving the field out, means unknown. In the Excel template the column is a dropdown with the codes `yes` and `no`, and a blank cell means unknown. Must be absent on `withdraw` and `delete`.
          examples:
            - true
    InterestFields:
      description: Section C. Interest.
      type: object
      properties:
        model_interest:
          type: string
          minLength: 1
          description: "A model from the `Lists` sheet of the template workbook, or `not_specified` when the customer named none. Not required: a lead without a model is accepted and marked incomplete."
          examples:
            - Model A
        purchase_timeframe:
          type: string
          enum:
            - Bulan ini
            - 1 bulan ke depan
            - 3 bulan ke depan
            - Belum tahu
        test_drive_requested:
          type: string
          enum:
            - "Y"
            - "N"
        enquiry_notes:
          type: string
          minLength: 1
          maxLength: 500
          description: The customer's own request only. No chat transcripts.
    LocationFields:
      description: Section D. Location and routing.
      type: object
      properties:
        customer_province:
          type: string
          minLength: 1
          description: A province from the `Lists` sheet of the template workbook.
          examples:
            - Jawa Barat
        customer_city:
          type: string
          minLength: 1
          description: "Kota or Kabupaten. Free text in version 1.2 of the template. Not required: a lead without a city is accepted and marked incomplete."
          examples:
            - Kota Bandung
        preferred_dealer_code:
          type: string
          minLength: 1
          description: Optional. The dealer code from the MarsX dealer list, for example `D0001`. Blank goes to triage. MarsX sets aside a code that is not on the list.
    AttributionFields:
      description: Section E. Source and attribution. Only `source_channel` is required.
      type: object
      properties:
        source_channel:
          type: string
          enum:
            - meta_lead_form
            - tiktok_lead_form
            - google_lead_form
            - other_ad_lead_form
            - chat
            - website_form
            - marketplace
            - other
          description: "How the lead reached the source, one of the eight values on the `Lists` sheet of the template. `meta_lead_form`, `tiktok_lead_form` and `google_lead_form` are the advertising platforms' own lead forms. `other_ad_lead_form` is any other advertising lead form. `chat` is an AI chat or a website chat. MarsX changes the list by written notice with 30 days lead time. Required for `new` and `update`."
          examples:
            - meta_lead_form
        campaign:
          type: string
          minLength: 1
        utm_source:
          type: string
          minLength: 1
        utm_medium:
          type: string
          minLength: 1
        utm_campaign:
          type: string
          minLength: 1
        ad_click_id:
          type: string
          minLength: 1
          description: The ad platform's click ID, for example Google `gcl_id`. Attribution only, never the lead ID.
        ad_id:
          type: string
          minLength: 1
        ad_creative_id:
          type: string
          minLength: 1
        session_id:
          type: string
          minLength: 1
          description: The source's own session reference, if it has one.
    ConsentFields:
      description: "Section F. Consent evidence. `contact_consent` and `consent_time` are required for `new` and `update`. `consent_reference`, `notice_version` and `consent_purpose` are optional: send them if you have them."
      type: object
      properties:
        contact_consent:
          type: string
          enum:
            - "yes"
          description: The customer agreed to be contacted. The only accepted value is `yes`. Any other value, or a missing field, makes the event invalid for `new` and `update`. In the Excel template the dropdown shows the label Yes.
          examples:
            - "yes"
        consent_time:
          type: string
          format: date-time
          description: When the customer agreed to be contacted. ISO 8601 date-time with an explicit UTC offset.
        consent_reference:
          type: string
          minLength: 1
          description: Optional. A reference to the partner's record of the actual consent. Not a yes or no value.
        notice_version:
          type: string
          minLength: 1
          description: Optional. The privacy notice wording the customer saw.
        consent_purpose:
          type: string
          minLength: 1
          description: Optional. Recorded per purpose. The `Lists` sheet lists `sales_follow_up` and `marketing`.
          examples:
            - sales_follow_up
    AiFields:
      description: Section G. AI-derived values. Optional. They never decide access or routing.
      type: object
      properties:
        ai_intent_label:
          type: string
          minLength: 1
          description: The partner's derived label.
        ai_summary:
          type: string
          minLength: 1
          description: Short. Stored separately from customer facts.
    PrivacyRequestFields:
      description: Section H. Privacy requests. Present on `withdraw` and `delete`. Must be absent on `new` and `update`.
      type: object
      properties:
        request_type:
          $ref: "#/components/schemas/RequestType"
        request_time:
          $ref: "#/components/schemas/RequestTime"
        request_reference:
          $ref: "#/components/schemas/RequestReference"

    PartnerLeadId:
      type: string
      pattern: "^[A-Za-z0-9._-]{1,64}$"
      description: The source's stable lead ID within one connection. Text, never reused within the connection. MarsX identifies a lead by its connection plus `partner_lead_id`. Identical IDs on two connections are independent leads. For ad lead forms, use the platform's own lead ID.
      examples:
        - SRC-TEST-0001
    SourceRevision:
      type: integer
      minimum: 0
      description: A whole number that increases with every change. Never repeated for different content. On a `lead.new` it may be left out, and MarsX then uses 1.
      examples:
        - 1
    DateTimeWithOffset:
      type: string
      format: date-time
      description: ISO 8601 date-time with an explicit UTC offset, for example `+07:00`. MarsX stores the instant and shows WIB. `created_at` is when the customer made the enquiry at the source, not when the event was sent. `updated_at` is when this revision was made. On a `lead.new` it may be left out, and MarsX then uses `created_at`.
      examples:
        - "2026-10-01T09:15:00+07:00"
    RequestType:
      type: string
      description: MarsX stores this value unchanged in its privacy request register. In release 1 a restriction and a withdrawal set the same marker.
      enum:
        - withdraw_consent
        - erasure
        - restriction
    RequestTime:
      type: string
      format: date-time
      description: When the customer made the privacy request.
      examples:
        - "2026-10-04T14:59:30+07:00"
    RequestReference:
      type: string
      pattern: "^[A-Za-z0-9._-]{1,64}$"
      description: The source's reference for the request. Together with the connection and `partner_lead_id` it de-duplicates privacy events.
      examples:
        - PRIV-TEST-0001
