{
  "openapi": "3.1.0",
  "info": {
    "title": "Sourceloop API",
    "version": "1.0.0",
    "description": "Read your marketing attribution data, and write outcomes back.\n\n## Authentication\nEvery endpoint takes a Sourceloop API key as a bearer token:\n\n```\nAuthorization: Bearer slk_live_...\n```\n\nCreate one in the app under Settings, Developers, API keys. Keys are scoped;\ncall `GET /me` to see what yours can do.\n\n## Reading a number correctly\nEvery response echoes the timezone, the currency and the resolved window in\n`meta`, plus a `definitions` block explaining each metric. A number from this\nAPI is never ambiguous about what it counts or which day boundary it used.\n\n## Building an automation\nPoll `GET /changes` with the cursor from your last run rather than refetching\na window and diffing. Every change carries a `source`, so your job can skip\nits own writes instead of reacting to them.\n\n## Naming\nResources use the same words as the app: contacts, companies, deals. The earlier\n`/conversions` and `/accounts` paths still work and return identical responses.\n\n## Rate limits\nCounted per workspace, not per key: minting a second key does not buy a second\nbudget. The sustained allowance is 60 requests per minute, 120 on Pro, 600 on\nBusiness and 1,200 on Agency. Every response carries `RateLimit-Limit`,\n`RateLimit-Remaining` and `RateLimit-Reset` (seconds), so a client can slow down\nbefore it is refused. Exceeding it returns `429` with `Retry-After`; retry after\nthat many seconds rather than immediately. Analytics reads are also capped at four\nconcurrent queries per workspace, so prefer one wide window over many narrow ones.\n\n## Errors\nErrors are RFC 9457 `application/problem+json` and carry a `remediation`\nfield saying what to do next. Quote `instance` (the request id) in support\nrequests.",
    "contact": {
      "name": "Sourceloop support",
      "url": "https://sourceloop.ai"
    }
  },
  "servers": [
    {
      "url": "https://app.sourceloop.ai/api/v1"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "A Sourceloop API key, e.g. slk_live_abc… Send it as a bearer token, never in a query string."
      }
    },
    "schemas": {
      "Problem": {
        "type": "object",
        "description": "RFC 9457 problem document.",
        "properties": {
          "type": {
            "type": "string",
            "format": "uri"
          },
          "title": {
            "type": "string"
          },
          "status": {
            "type": "integer"
          },
          "detail": {
            "type": "string"
          },
          "remediation": {
            "type": "string",
            "description": "What to do about it."
          },
          "allowed": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Valid values, when the failure was an unrecognised name."
          },
          "instance": {
            "type": "string",
            "description": "Request id. Quote this in support requests."
          }
        }
      },
      "Meta": {
        "type": "object",
        "properties": {
          "website_id": {
            "type": "string",
            "format": "uuid"
          },
          "website": {
            "type": "string"
          },
          "timezone": {
            "type": "string",
            "description": "The workspace timezone the window was resolved in."
          },
          "currency": {
            "type": "string"
          },
          "window": {
            "type": "object",
            "properties": {
              "from": {
                "type": "string",
                "format": "date-time"
              },
              "to": {
                "type": "string",
                "format": "date-time"
              },
              "expression": {
                "type": "string",
                "nullable": true
              }
            }
          },
          "row_count": {
            "type": "integer"
          },
          "truncated": {
            "type": "boolean",
            "description": "True when more rows exist than were returned."
          },
          "definitions": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          }
        }
      }
    },
    "parameters": {
      "website": {
        "name": "website",
        "in": "query",
        "schema": {
          "type": "string"
        },
        "description": "Website domain, e.g. acme.com. Required only when the key covers more than one."
      },
      "period": {
        "name": "period",
        "in": "query",
        "schema": {
          "type": "string"
        },
        "description": "Plain-language period: \"last 30 days\", \"yesterday\", \"July 2026\", or \"2026-07-01..2026-07-31\". Ignored when from/to are given.",
        "example": "last 30 days"
      },
      "from": {
        "name": "from",
        "in": "query",
        "schema": {
          "type": "string",
          "format": "date-time"
        }
      },
      "to": {
        "name": "to",
        "in": "query",
        "schema": {
          "type": "string",
          "format": "date-time"
        }
      }
    },
    "responses": {
      "Problem": {
        "description": "Something was wrong with the request or the credential.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "RateLimited": {
        "description": "Too many requests for this workspace in the current minute. Wait Retry-After seconds and repeat the request unchanged.",
        "headers": {
          "Retry-After": {
            "schema": {
              "type": "integer"
            },
            "description": "Seconds until the window resets."
          },
          "RateLimit-Limit": {
            "schema": {
              "type": "integer"
            }
          },
          "RateLimit-Remaining": {
            "schema": {
              "type": "integer"
            }
          },
          "RateLimit-Reset": {
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      }
    }
  },
  "paths": {
    "/me": {
      "get": {
        "summary": "What this key is and what it can do",
        "description": "The first call to make. Returns the scopes, plan and websites this key reaches.",
        "operationId": "getMe",
        "responses": {
          "200": {
            "description": "Key identity"
          },
          "403": {
            "$ref": "#/components/responses/Problem"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/websites": {
      "get": {
        "summary": "Websites this key can reach",
        "description": "With each one's timezone and currency, which every other response is expressed in.",
        "operationId": "listWebsites",
        "responses": {
          "200": {
            "description": "Websites"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/schema": {
      "get": {
        "summary": "Every metric, dimension and filter operator",
        "description": "Generated from the same registry the query compiler uses, so it cannot drift from behaviour. Read this instead of guessing names.",
        "operationId": "getSchema",
        "responses": {
          "200": {
            "description": "Schema document"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/metrics": {
      "get": {
        "summary": "Aggregate metrics, breakdowns and timeseries",
        "description": "Omit `group_by` for a single total. Add it for one row per dimension value,\nwhich also returns the window totals so the share each row represents is visible.\nAdd `granularity` for a timeseries.\n\nNote that a total and a timeseries are different queries: unique visitors cannot\nbe summed across days without counting returning people twice, so the aggregate\nis computed once across the whole window.",
        "operationId": "getMetrics",
        "parameters": [
          {
            "$ref": "#/components/parameters/website"
          },
          {
            "$ref": "#/components/parameters/period"
          },
          {
            "$ref": "#/components/parameters/from"
          },
          {
            "$ref": "#/components/parameters/to"
          },
          {
            "name": "metrics",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Comma-separated. One or more of: visitors, sessions, pageviews, events, conversions, influenced_conversions, unique_converters, revenue, lifetime_value, customers, avg_ltv, repeat_rate, avg_orders, cohort_mrr, revenue_per_customer, milestones, conversion_rate, pageviews_per_session, revenue_per_visitor, spend, impressions, clicks, roas, cac, ltv_cac",
            "example": "visitors,conversions,conversion_rate"
          },
          {
            "name": "group_by",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Split by one dimension, or up to four comma-separated for a nested breakdown such as channel,source. Omit for a single total. One of: channel, source, medium, campaign, utm_term, utm_content, referrer_domain, page, landing_page, hostname, page_title, country, region, city, device_type, browser, operating_system, resolution, platform, ad_campaign, ad_set, ad, cohort\n\nThree of those are answered by a different store and behave differently:\n- platform and ad_campaign join ad SPEND to the campaign that acquired each customer, which is what makes cac and ltv_cac available. They take one dimension at a time and cannot be crossed with traffic dimensions.\n- resolution splits outcomes by WHY they had no journey (unmatched, predates tracking, imported contact) and is only available alongside credited metrics.\n- cohort returns a retention curve instead of a ranking: rows are (cohort, period_index) and the window chooses which cohorts appear, never which payments count."
          },
          {
            "name": "filter",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "style": "form",
            "explode": true,
            "description": "Repeatable, ANDed. Form: dimension:operator:value. Operators: eq, ne, in, nin, contains, not_contains. Filterable dimensions: channel, source, medium, campaign, utm_term, utm_content, referrer_domain, page, landing_page, hostname, page_title, country, region, city, device_type, browser, operating_system, resolution, event_name, event_type, identified, platform, ad_campaign, ad_set, ad, campaign_type, match_type, company_domain, crm_stage, crm_account_stage",
            "example": "channel:in:paid_search,paid_social"
          },
          {
            "name": "granularity",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "day"
              ]
            },
            "description": "Return a daily time series instead of a single total. Only day is supported: the underlying store buckets by calendar day in the website timezone, so hour, week and month are rejected rather than silently returning days under another label."
          },
          {
            "name": "attribution",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "example": "first_touch,last_touch",
            "description": "Credit conversions to a touchpoint using an attribution model instead of counting the event where the conversion fired. One of first_touch, last_touch, linear, u_shaped, time_decay, or up to five comma-separated to compare them in one response, which guarantees every model saw the same window and filters. Requires group_by, and only conversions and revenue can be attributed: a visitor has one channel at a time. When several models are given, each row nests one object per model; a null metric means that model truncated before reaching this value (see meta.truncated_models), which is not the same as zero."
          },
          {
            "name": "stages",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "example": "deal_won",
            "description": "Count CRM and lifecycle milestones instead of tracked conversions: deal_created, deal_won, mql, trial_started, trial_converted, subscription_started, subscription_churned and the rest of the workspace stage vocabulary. A milestone is a different KIND of outcome from a conversion rather than a category of one, so the two are never blended. A milestone has no channel of its own, so this implies an attribution model and defaults to last_touch; the model used is echoed in meta.definitions. Deals are credited across the whole buying committee."
          },
          {
            "name": "conjunction",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "and",
                "or"
              ]
            },
            "description": "How repeated filters combine. Default and."
          },
          {
            "name": "only_paid",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "Restrict to paid traffic: a paid medium, or the presence of an ad click id."
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 50,
              "maximum": 1000
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Rows plus meta",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "additionalProperties": true
                      }
                    },
                    "meta": {
                      "$ref": "#/components/schemas/Meta"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Problem"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/ads/performance": {
      "get": {
        "summary": "Ad platform spend and delivery",
        "description": "`level` is REQUIRED. Spend is stored once per breakdown grain, so a query that\ndoes not pin one, or that mixes two, multiplies it.\n\nMetrics named `platform_*` are what the ad platform reports under its own\nattribution window and view-through rules. They will not match Sourceloop\nattributed conversions, and that difference is expected.\n\nAmounts are in the AD ACCOUNT currency, which may differ from the workspace\ncurrency; `meta.currency_source` says which you got.",
        "operationId": "getAdPerformance",
        "parameters": [
          {
            "$ref": "#/components/parameters/website"
          },
          {
            "$ref": "#/components/parameters/period"
          },
          {
            "name": "level",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "account",
                "campaign",
                "ad_set",
                "ad",
                "keyword",
                "search_term",
                "placement",
                "audience",
                "age_gender",
                "geo",
                "device",
                "asset",
                "asset_group",
                "product",
                "listing_group",
                "network"
              ]
            },
            "description": "Grain to report at. \"campaign\" is the usual view."
          },
          {
            "name": "metrics",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Comma-separated. One or more of: spend, impressions, clicks, platform_conversions, platform_revenue, ctr, cpc, cpm, platform_roas, platform_conversion_rate"
          },
          {
            "name": "breakdown",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "One row per item at that level."
          },
          {
            "name": "platform",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "example": "google_ads"
          }
        ],
        "responses": {
          "200": {
            "description": "Ad rows"
          },
          "400": {
            "$ref": "#/components/responses/Problem"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/funnels": {
      "get": {
        "summary": "Stage-to-stage conversion",
        "description": "How many entities entered each stage of a funnel, and the drop-off between\nthem. Pass `stages` in the order they happen; GET /v1/pipelines lists the\nstage vocabulary this workspace uses.\n\nCounted over the WHOLE population, including people the tracker never saw:\nimported contacts, deals closed over the phone, customers who predate the\nscript. That is why these rates are lower than a session-based funnel, and\nwhy they are the honest ones.\n\nThese are not a cohort. Somebody counted at a late stage may have entered the\nfirst one before the window, so a step rate above 100% is possible.\n\nTo credit a stage to the marketing that produced it, use GET /v1/metrics with\n`stages` and an attribution model instead.",
        "operationId": "getFunnel",
        "parameters": [
          {
            "$ref": "#/components/parameters/website"
          },
          {
            "$ref": "#/components/parameters/period"
          },
          {
            "name": "stages",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "lead,mql,customer",
            "description": "Comma-separated stage keys, in funnel order."
          }
        ],
        "responses": {
          "200": {
            "description": "One row per stage"
          },
          "400": {
            "$ref": "#/components/responses/Problem"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/paths": {
      "get": {
        "summary": "The touch sequences that lead to outcomes",
        "description": "Which ordered SEQUENCES of touchpoints end in an outcome, and what each is\nworth. This is what a per-channel breakdown cannot show: the channels that\nonly work together.\n\nJourneys longer than `max_touches` keep their first N steps and carry a\ntrailing \"...\" , so two long journeys that begin the same way group together\ninstead of each becoming a row of one.\n\nA path of [\"Unattributed\"] is an outcome with no journey at all, which is a\nreal outcome rather than missing data.",
        "operationId": "getPaths",
        "parameters": [
          {
            "$ref": "#/components/parameters/website"
          },
          {
            "$ref": "#/components/parameters/period"
          },
          {
            "name": "step",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "channel",
                "source",
                "medium",
                "campaign"
              ]
            },
            "description": "What each step is labelled with. Default channel."
          },
          {
            "name": "stages",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Paths to CRM milestones instead of tracked conversions."
          },
          {
            "name": "types",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "events",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "max_touches",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 5,
              "maximum": 10
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 50,
              "maximum": 200
            }
          }
        ],
        "responses": {
          "200": {
            "description": "One row per distinct path"
          },
          "400": {
            "$ref": "#/components/responses/Problem"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/products": {
      "get": {
        "summary": "Per-product sales for a connected store",
        "description": "Revenue, units, orders and margin per product, NET of refunds.\n\nMargin covers only the share of revenue whose cost the merchant has set, and\n`meta.cost_coverage_pct` reports that share. Below 100 the margin describes\npart of the catalogue rather than all of it.\n\nAn empty list with `meta.no_data_reason` means no store is connected or no\norders were placed, which is not the same as selling nothing.",
        "operationId": "getProducts",
        "parameters": [
          {
            "$ref": "#/components/parameters/website"
          },
          {
            "$ref": "#/components/parameters/period"
          },
          {
            "name": "platform",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "shopify"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 100,
              "maximum": 500
            }
          }
        ],
        "responses": {
          "200": {
            "description": "One row per product"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/attribution/coverage": {
      "get": {
        "summary": "How much revenue attribution explains, and why not",
        "description": "Call this before concluding that tracking is broken. Recorded revenue is\nclassified per fact into: `attributed`, `predates_tracking` (the CUSTOMER\narrived before the tracker existed), `imported_contact` (loaded from a CRM or\nspreadsheet), and `no_visitor` / `no_sessions` (the tracker was running and\nthe person still could not be tied to a visit).\n\nThe first three are permanent facts about the business; only the last two\nindicate a problem. `meta.expected_gap_amount` and\n`meta.worth_investigating_amount` split them, because summing everything into\none \"unattributed\" number reported a working install as 93% broken on the\nworkspace this was measured on.\n\n`predates_tracking` is keyed on when the relationship started, not on the\npayment date: classifying per payment put 92.8% of the money in the wrong\nbucket.\n\nThese are revenue FACTS as recorded, while /metrics counts credit as\ndistributed, so the two do not tie out line for line.",
        "operationId": "getAttributionCoverage",
        "parameters": [
          {
            "$ref": "#/components/parameters/website"
          },
          {
            "$ref": "#/components/parameters/period"
          }
        ],
        "responses": {
          "200": {
            "description": "One row per bucket"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/contacts": {
      "get": {
        "summary": "Your contacts, one row per conversion",
        "description": "One row per CONVERSION, not per person: someone who converts twice appears\ntwice. `identity_id` is on every row for callers who need to group by person.\n\nReturns your leads with their contact details. New keys include the `pii:read`\nscope by default, so email and phone come back in full.\n\nIf a key is created WITHOUT that scope, email, phone and name are masked and the\nemail domain is preserved, so the row still identifies the company. Use that for\nkeys given to contractors, reporting tools, or AI assistants.\n\nPaginate with `cursor`, taking `next_cursor` from the previous response.",
        "operationId": "listContacts",
        "parameters": [
          {
            "$ref": "#/components/parameters/website"
          },
          {
            "$ref": "#/components/parameters/period"
          },
          {
            "name": "type",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "example": "Web Form"
          },
          {
            "name": "event_name",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "email",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Requires the pii:read scope: searching by email is reading it."
          },
          {
            "name": "include_spam",
            "in": "query",
            "schema": {
              "type": "boolean",
              "default": false
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 50,
              "maximum": 200
            }
          },
          {
            "name": "total_count",
            "in": "query",
            "schema": {
              "type": "boolean",
              "default": false
            },
            "description": "Include meta.total_count, the exact number of matching rows. Off by default because counting scans every match while the page itself reads one page, so on a large workspace the count costs far more than the rows. Paginate with has_more and next_cursor unless you are rendering \"70 of 366\"."
          }
        ],
        "responses": {
          "200": {
            "description": "Conversions"
          },
          "403": {
            "$ref": "#/components/responses/Problem"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/contacts/{id}": {
      "get": {
        "summary": "One conversion, with its deals and value history",
        "description": "Adds the CRM deals this person is attached to and the full history of value changes, each with the reason and the system that made it.",
        "operationId": "getContact",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Conversion detail"
          },
          "404": {
            "$ref": "#/components/responses/Problem"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "patch": {
        "summary": "Write an outcome back",
        "description": "How your CRM tells Sourceloop that a lead became worth 14,000, which turns\ncost-per-lead reporting into cost-per-revenue reporting.\n\nSend an `Idempotency-Key` header. A retried request returns the original\nresult rather than writing twice, and a request that changes nothing writes\nnothing at all. Both matter because a conversion write propagates to your\nconnected CRM and to ad-platform conversion upload.\n\nAttribution fields are computed by Sourceloop and cannot be set.",
        "operationId": "updateContact",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "Idempotency-Key",
            "in": "header",
            "schema": {
              "type": "string"
            },
            "description": "A unique token per distinct request. Strongly recommended."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "status": {
                    "type": "string"
                  },
                  "sales_value": {
                    "type": "number"
                  },
                  "quote_value": {
                    "type": "number"
                  },
                  "currency": {
                    "type": "string"
                  },
                  "notes": {
                    "type": "string"
                  },
                  "lead_status_raw": {
                    "type": "string"
                  },
                  "lifecycle_stage_raw": {
                    "type": "string"
                  },
                  "is_spam": {
                    "type": "boolean"
                  }
                }
              },
              "example": {
                "status": "won",
                "sales_value": 14000,
                "currency": "USD"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated, or unchanged"
          },
          "404": {
            "$ref": "#/components/responses/Problem"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/contacts/{id}/journey": {
      "get": {
        "summary": "Every session behind one conversion, in order",
        "description": "The evidence the attribution numbers rest on: how this person arrived each\ntime, and what they did once there.\n\nDevices are merged via the identity graph first, so someone who browsed on a\nphone and converted on a laptop is one timeline rather than two half-journeys.\n\nWhen no raw events exist (a CRM import, or a server-side conversion) the\ntimeline is reconstructed from stored first-touch and last-touch attribution\nand flagged synthetic:true. Do not present that as a complete history.",
        "operationId": "getContactJourney",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 365
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 2000
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Journey"
          },
          "404": {
            "$ref": "#/components/responses/Problem"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/companies/{id}/journey": {
      "get": {
        "summary": "The account journey: everyone at the company, merged",
        "description": "Every session by every person at the company, ordered across all of them, because in B2B the person who first read a blog post is rarely the person who signs. people_count reports how many were merged.",
        "operationId": "getCompanyJourney",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 365
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Journey"
          },
          "404": {
            "$ref": "#/components/responses/Problem"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/deals/{id}/journey": {
      "get": {
        "summary": "The marketing behind one deal",
        "description": "Every session by every contact attached to the deal. The closed loop stated in full: not \"paid search influenced 60,000 of pipeline\", but the sessions that claim actually rests on.",
        "operationId": "getDealJourney",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 365
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Journey"
          },
          "404": {
            "$ref": "#/components/responses/Problem"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/events": {
      "post": {
        "summary": "Send server-side events",
        "description": "For anything the browser cannot see: OAuth callbacks, payment webhooks, queue\nworkers. Events are validated and classified with exactly the same rules the\nbrowser tracker applies, so a server-side conversion attributes identically.\n\nEach event needs an email, a phone, or an `anonymous_id` to be attributable.\nRead `_sl_aid` from the visitor's cookie and pass it as `anonymous_id` to\nstitch a backend event to their browsing history.\n\nPartial success: one malformed event does not discard the batch.",
        "operationId": "sendEvents",
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "events"
                ],
                "properties": {
                  "website": {
                    "type": "string"
                  },
                  "events": {
                    "type": "array",
                    "maxItems": 200,
                    "items": {
                      "type": "object",
                      "properties": {
                        "event_type": {
                          "type": "string",
                          "example": "custom",
                          "x-sourceloop-group": "Event"
                        },
                        "event_name": {
                          "type": "string",
                          "example": "signup_completed",
                          "x-sourceloop-group": "Event"
                        },
                        "occurred_at": {
                          "type": "string",
                          "format": "date-time",
                          "x-sourceloop-group": "Event"
                        },
                        "email": {
                          "type": "string",
                          "format": "email",
                          "x-sourceloop-group": "Identity"
                        },
                        "phone": {
                          "type": "string",
                          "x-sourceloop-group": "Identity"
                        },
                        "anonymous_id": {
                          "type": "string",
                          "description": "The visitor's _sl_aid cookie value.",
                          "x-sourceloop-group": "Identity"
                        },
                        "revenue": {
                          "type": "number",
                          "x-sourceloop-group": "Value"
                        },
                        "currency": {
                          "type": "string",
                          "x-sourceloop-group": "Value"
                        },
                        "properties": {
                          "type": "object",
                          "additionalProperties": true,
                          "x-sourceloop-group": "Value"
                        }
                      }
                    }
                  }
                }
              },
              "example": {
                "events": [
                  {
                    "event_type": "custom",
                    "event_name": "signup_completed",
                    "email": "jane@acme.com",
                    "anonymous_id": "a1b2c3"
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted and rejected counts"
          },
          "400": {
            "$ref": "#/components/responses/Problem"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/deals": {
      "get": {
        "summary": "Your pipeline, with the attribution we computed for it",
        "description": "Deals mirrored from your CRM, each carrying the first-touch and last-touch\nattribution Sourceloop derived by walking the touchpoints of everyone linked\nto the deal. Your CRM does not hold those fields, which is the reason to read\ndeals here rather than from the CRM API.\n\nEvery row includes `stage.normalized_bucket` (open, won, lost, other). Stage\nnames are chosen by the customer, so a report that trusts the label breaks the\nday someone renames a stage.\n\nReturns a connection-required error, not an empty list, when no CRM is\nconnected. \"No deals\" and \"no CRM\" are different answers.",
        "operationId": "listDeals",
        "parameters": [
          {
            "$ref": "#/components/parameters/website"
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "open",
                "won",
                "lost"
              ]
            }
          },
          {
            "name": "pipeline_id",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "stage_id",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "account_id",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "owner_email",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "min_amount",
            "in": "query",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "closed_after",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "closed_before",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "channel",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "First-touch channel that introduced the company."
          },
          {
            "name": "source",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "First-touch source."
          },
          {
            "name": "campaign",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "First-touch campaign."
          },
          {
            "name": "latest_channel",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sort",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "amount",
                "updated"
              ]
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 50,
              "maximum": 200
            }
          },
          {
            "name": "total_count",
            "in": "query",
            "schema": {
              "type": "boolean",
              "default": false
            },
            "description": "Include meta.total_count, the exact number of matching rows. Off by default because counting scans every match while the page itself reads one page, so on a large workspace the count costs far more than the rows. Paginate with has_more and next_cursor unless you are rendering \"70 of 366\"."
          }
        ],
        "responses": {
          "200": {
            "description": "Deals with attribution"
          },
          "400": {
            "$ref": "#/components/responses/Problem"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/deals/{id}": {
      "get": {
        "summary": "One deal, with its people and its history",
        "description": "Adds the contacts on the deal, the append-only stage history (which survives a stage being renamed later, so time-in-stage stays computable) and the pipeline it belongs to.",
        "operationId": "getDeal",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deal detail"
          },
          "404": {
            "$ref": "#/components/responses/Problem"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "patch": {
        "summary": "Move a deal, or change its value, in your CRM",
        "description": "Your CRM owns every field on a deal, so this does not write a value that lives\nonly here: the change is saved and queued for push back to HubSpot or Pipedrive.\nCheck `push.queued` in the response. When it is false the value was saved but\nNOT sent (outbound disabled, or the connection is not active) and the next sync\nfrom the CRM will overwrite it.\n\n`status` is translated for you. HubSpot has no writable status field, because won\nand lost are stages there, so {\"status\":\"won\"} is resolved to that pipeline's won\nstage and `notes` reports which. Pipedrive accepts status directly. If the pipeline\nhas no matching stage, HubSpot returns 422 asking for a stage_id from /pipelines.\n\nSend an `Idempotency-Key`. A retry returns the original result rather than pushing\nto the CRM twice, and a request that changes nothing writes and pushes nothing.\n\n`expected_revenue` is not writable: both providers compute it from amount and stage\nprobability. Attribution is computed by Sourceloop and cannot be set.",
        "operationId": "updateDeal",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "Idempotency-Key",
            "in": "header",
            "schema": {
              "type": "string"
            },
            "description": "A unique token per distinct request. Strongly recommended: this writes to your CRM."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "amount": {
                    "type": "number"
                  },
                  "currency": {
                    "type": "string"
                  },
                  "close_date": {
                    "type": "string",
                    "format": "date"
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "open",
                      "won",
                      "lost"
                    ]
                  },
                  "stage_id": {
                    "type": "string",
                    "format": "uuid",
                    "description": "From GET /pipelines. Takes precedence over status."
                  }
                }
              },
              "example": {
                "status": "won",
                "amount": 14000
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated and queued for push, or unchanged"
          },
          "403": {
            "$ref": "#/components/responses/Problem"
          },
          "404": {
            "$ref": "#/components/responses/Problem"
          },
          "422": {
            "$ref": "#/components/responses/Problem"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/companies/{id}": {
      "get": {
        "summary": "One company, with its deals and its people",
        "description": "The account-based view: firmographics, pipeline rollups, attribution, every deal attached to the company, and the people from it aggregated so the same person is not counted twice.",
        "operationId": "getCompany",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Company detail"
          },
          "404": {
            "$ref": "#/components/responses/Problem"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "patch": {
        "summary": "Correct a company's firmographics",
        "description": "Most companies here were discovered from traffic and identity stitching rather\nthan imported from a CRM, so nobody else owns them and this is a plain local\nwrite. When a company IS linked to a CRM connection the write still lands and\nthe response carries a `warning` saying the next sync will overwrite it.\n\nEngagement counts, deal rollups and every first_*/latest_* attribution column\nare computed by Sourceloop and are refused rather than ignored.",
        "operationId": "updateCompany",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "Idempotency-Key",
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "company_name": {
                    "type": "string"
                  },
                  "industry": {
                    "type": "string"
                  },
                  "size_range": {
                    "type": "string"
                  },
                  "employee_count": {
                    "type": "integer"
                  },
                  "annual_revenue": {
                    "type": "number"
                  },
                  "country": {
                    "type": "string"
                  },
                  "region": {
                    "type": "string"
                  },
                  "city": {
                    "type": "string"
                  },
                  "website_url": {
                    "type": "string"
                  },
                  "description": {
                    "type": "string"
                  },
                  "linkedin_url": {
                    "type": "string"
                  }
                }
              },
              "example": {
                "industry": "Logistics",
                "employee_count": 240
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated, or unchanged"
          },
          "403": {
            "$ref": "#/components/responses/Problem"
          },
          "404": {
            "$ref": "#/components/responses/Problem"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/companies": {
      "get": {
        "summary": "Companies, with firmographics, pipeline rollups and attribution",
        "description": "Unlike deals, this does NOT require a CRM. A company record comes either from a\nconnected CRM or from our own domain resolution off a captured lead email, and\n`source` on each row says which. So this answers \"which companies are on my\nsite\" even with no CRM at all.\n\nPersonal email domains (gmail and similar) are excluded by default: they are one\nperson, not a company. Pass include_personal=true to keep them.",
        "operationId": "listCompanies",
        "parameters": [
          {
            "$ref": "#/components/parameters/website"
          },
          {
            "name": "domain",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "industry",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "country",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "lifecycle_stage",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "has_deals",
            "in": "query",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "has_open_deals",
            "in": "query",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "channel",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "First-touch channel."
          },
          {
            "name": "include_personal",
            "in": "query",
            "schema": {
              "type": "boolean",
              "default": false
            }
          },
          {
            "name": "sort",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "last_seen",
                "pipeline",
                "won",
                "conversions"
              ]
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 50,
              "maximum": 200
            }
          },
          {
            "name": "total_count",
            "in": "query",
            "schema": {
              "type": "boolean",
              "default": false
            },
            "description": "Include meta.total_count, the exact number of matching rows. Off by default because counting scans every match while the page itself reads one page, so on a large workspace the count costs far more than the rows. Paginate with has_more and next_cursor unless you are rendering \"70 of 366\"."
          }
        ],
        "responses": {
          "200": {
            "description": "Companies"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/pipelines": {
      "get": {
        "summary": "Pipelines and their stages, in order",
        "description": "Needed to interpret a deal's stage: which stages exist, what order they run in, their win probability, and which of the customer's stage names actually mean won.",
        "operationId": "listPipelines",
        "parameters": [
          {
            "$ref": "#/components/parameters/website"
          }
        ],
        "responses": {
          "200": {
            "description": "Pipelines with stages"
          },
          "400": {
            "$ref": "#/components/responses/Problem"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/changes": {
      "get": {
        "summary": "What changed since your last run",
        "description": "The endpoint automations are built on. Poll it with the cursor from your last\nrun instead of refetching a window and diffing client-side.\n\nEvery change carries a `source`, so your job can skip its own writes rather\nthan reacting to them and looping.\n\nOrdered newest first. Keeps 90 days.",
        "operationId": "listChanges",
        "parameters": [
          {
            "name": "since",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Defaults to the last 24 hours."
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "types",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Comma-separated: conversion.created, conversion.value_changed, stage.changed, deal.stage_changed"
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 100,
              "maximum": 500
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Changes"
          },
          "400": {
            "$ref": "#/components/responses/Problem"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    }
  },
  "x-scopes": {
    "metrics:read": "Read aggregated metrics, breakdowns and timeseries",
    "conversions:read": "List and read conversions",
    "conversions:write": "Update a conversion's status, value and notes. Creating one is done by sending an event, so that it goes through attribution.",
    "companies:read": "Read companies, their firmographics and engagement",
    "companies:write": "Update company firmographics",
    "deals:read": "Read CRM deals, pipelines and stages, including deal values",
    "deals:write": "Move a deal or change its value. Writes through to the CRM that owns the deal.",
    "events:write": "Send server-side events",
    "pii:read": "Include email, phone and name in responses"
  },
  "x-mcp": {
    "url": "https://app.sourceloop.ai/api/mcp",
    "description": "Model Context Protocol endpoint. Add it to Claude, ChatGPT or any MCP client with the same API key to ask questions of your data in natural language."
  }
}