openapi: 3.1.0 info: title: Closebuy Mother App API (for Studio) version: "1.1.0" description: | Public read API exposed by the **Closebuy mother app** — the single source of truth for organisations, users and leads. **Closebuy Studio** consumes this API server-side using a long-lived bearer token (`CLOSEBUY_API_KEY`). Studio does **not** expose its own API. All data flows **Studio → Mother (pull)**. When Studio needs fresh data it calls the endpoints below; the mother app never pushes to Studio. SSO is a separate user-agent redirect (not a JSON API) — see the integration README. servers: - url: "{closebuyApiBaseUrl}" description: Closebuy mother app variables: closebuyApiBaseUrl: default: https://huvqmibfyuasnvyfguki.functions.supabase.co/studio-api components: securitySchemes: MotherApiKey: type: http scheme: bearer bearerFormat: opaque description: | Issued by the mother app. Stored in Studio as `CLOSEBUY_API_KEY`. Sent as `Authorization: Bearer ` on every Studio → Mother call. schemas: Error: type: object required: [error] properties: error: { type: string, example: "unauthorized" } message: { type: string } BrandTokens: type: object required: [primary, secondary, accent, bg, text, muted, fontDisplay, fontBody] properties: logoUrl: { type: string, format: uri, nullable: true } logoMarkUrl: { type: string, format: uri, nullable: true } primary: { type: string, example: "#FB3C95" } secondary: { type: string, example: "#1A1A1A" } accent: { type: string, example: "#FA83BC" } bg: { type: string, example: "#FFFFFF" } text: { type: string, example: "#1F2540" } muted: { type: string, example: "#6B7280" } fontDisplay: { type: string, example: "Plus Jakarta Sans" } fontBody: { type: string, example: "Inter" } SenderBlock: type: object required: [senderName, companyName, companyAddress] properties: senderName: { type: string } senderTitle: { type: string } senderEmail: { type: string, format: email } senderPhone: { type: string } senderAvatarUrl: { type: string, format: uri, nullable: true } senderSignatureUrl: { type: string, format: uri, nullable: true } companyName: { type: string } companyAddress: { type: string } companyVat: { type: string } companyWebsite: { type: string } CloseBuyOrg: type: object required: [id, name, brand, sender] properties: id: { type: string, example: "org_closebuy_demo" } name: { type: string } description: { type: string, nullable: true } industry: { type: string } businessType: { type: string, nullable: true } targetMarket: { type: string, nullable: true } serviceDescription: { type: string, nullable: true } aiAnalysisPrompt: { type: string, nullable: true } onboardingComplete: { type: boolean } createdAt: { type: string, format: date-time, nullable: true } updatedAt: { type: string, format: date-time, nullable: true } websiteUrl: { type: string, format: uri, nullable: true, description: "Alias for `website`." } website: { type: string, format: uri, nullable: true } email: { type: string, format: email, nullable: true } phone: { type: string, nullable: true } cvrNumber: { type: string, nullable: true, description: "Danish CVR registration number." } address: { type: string, nullable: true } postalCode: { type: string, nullable: true } city: { type: string, nullable: true } latitude: { type: number, nullable: true } longitude: { type: number, nullable: true } employeeCount: { type: integer, nullable: true } publicHost: type: string nullable: true description: "Optional custom host for landing pages (e.g. 'acme.heyrobot.com')." brand: { $ref: "#/components/schemas/BrandTokens" } sender: { $ref: "#/components/schemas/SenderBlock" } CloseBuyUser: type: object required: [id, email, orgId, role] properties: id: { type: string } email: { type: string, format: email } name: { type: string } orgId: { type: string } role: type: string enum: [owner, manager, member] CloseBuyLead: type: object required: [id, company, address, zip, city, country] description: | A single lead row, hydrated with every CVR/enrichment field the mother app has on file plus the saved-list context the lead came from. All unknown/legacy fields from the source row are also returned under `raw` for forward compatibility. properties: # ----- Identity ----- id: { type: string, description: "Stable lead id (company id or CVR)." } companyId: { type: string, nullable: true } cvrNumber: { type: string, nullable: true } company: { type: string } companyName: { type: string } legalForm: { type: string, nullable: true } companyStatus: { type: string, nullable: true, example: "NORMAL" } foundingDate: { type: string, format: date, nullable: true } ceasedDate: { type: string, format: date, nullable: true } advertisingProtected: { type: boolean, nullable: true } # ----- Address / location ----- address: { type: string } address2: { type: string, nullable: true } zip: { type: string } postalCode: { type: string } city: { type: string } country: { type: string, example: "Danmark" } fullAddress: { type: string } latitude: { type: number, nullable: true, description: "Latitude of the lead's physical location (production-unit address for P-unit leads, HQ otherwise)." } longitude: { type: number, nullable: true, description: "Longitude of the lead's physical location." } pNumber: { type: string, nullable: true, description: "Production-unit number (P-nummer) when the lead is a production unit rather than the legal entity." } isProductionUnit: { type: boolean, description: "True when this lead represents a production unit." } parentCompanyName: { type: string, nullable: true, description: "Legal-entity name when the lead is a production unit." } distanceKm: { type: number, nullable: true, description: "Great-circle distance from search origin (km). Present when the list was built from a location-based search." } drivingDistanceKm: { type: number, description: "Driving distance from origin per saved route_data. **Only present when route data exists** for the lead." } drivingDurationMin: { type: number, description: "Driving minutes from origin per saved route_data. **Only present when route data exists**." } distanceMin: { type: integer, description: "Driving minutes (rounded) — only present when route data exists." } # ----- Industry / size ----- industry: { type: string } industryCode: { type: string, nullable: true, example: "683210" } employeeCount: { type: integer, nullable: true } numberOfUnits: { type: integer, nullable: true } totalArea: { type: number, nullable: true, description: "Total BBR floor area (m²)." } companySize: { type: string, nullable: true, example: "10-49 ansatte" } # ----- Contact ----- email: { type: string, format: email, nullable: true } phone: { type: string, nullable: true } phoneSecondary: { type: string, nullable: true } website: { type: string, nullable: true } latestUnitContact: type: array nullable: true items: { type: string } description: "Phone/email pair from the most recent P-unit." # ----- Social ----- socialLinkedin: { type: string, nullable: true } socialFacebook: { type: string, nullable: true } socialTwitter: { type: string, nullable: true } socialInstagram: { type: string, nullable: true } # ----- People ----- recipientName: { type: string } recipientJobTitle: { type: string } participantsManagement: { type: array, items: { type: string } } participantsFounders: { type: array, nullable: true, items: { type: string } } participantsBoard: { type: array, nullable: true, items: { type: string } } participantsAuditors: { type: array, nullable: true, items: { type: string } } participantsRealOwners: { type: array, nullable: true, items: { type: string } } participantsLegalOwners: { type: array, nullable: true, items: { type: string } } participantsResponsibleOwners: { type: array, nullable: true, items: { type: string } } # ----- Financial / risk ----- totalActive: { type: integer, nullable: true, description: "Active CVR registrations across history." } totalAnnualWork: { type: integer, nullable: true } totalBankruptcy: { type: integer, nullable: true } totalUnderBankruptcy: { type: integer, nullable: true } creditInformation: { type: object, nullable: true, additionalProperties: true } financials: { type: object, nullable: true, additionalProperties: true, description: "Latest årsrapport snapshot when attached to the lead." } # ----- Descriptions / enrichment ----- description: { type: string, nullable: true } companyDescription: { type: string, nullable: true } enrichmentData: { type: object, nullable: true, additionalProperties: true } leadLogoUrl: { type: string, format: uri, nullable: true, description: "Deprecated alias of logoUrl." } logoUrl: type: string format: uri nullable: true description: "Best logo URL discovered on the company website (any size; PNG or SVG preferred). URL only — no file is stored in Closebuy." imageAssets: type: array description: "Up to 10 images scraped from the company website. Each image has a minimum resolution of 800×800. URLs only — no files are stored in Closebuy. SVGs and icons are excluded from image assets (use logoUrl for SVG logos)." items: type: object required: [url, width, height] properties: url: { type: string, format: uri } width: { type: integer, minimum: 800 } height: { type: integer, minimum: 800 } type: { type: string, enum: [png, jpeg, webp, gif] } aiHook: { type: string, nullable: true } aiScore: type: number nullable: true description: "AI fit score 0-100 (mirrors aiAnalysis.score) — surfaced as a top-level field for convenience." aiReason: type: string nullable: true description: "Short (≤100 chars) AI reasoning for the score (mirrors aiAnalysis.reason)." aiIndicators: type: array nullable: true items: { type: string } description: "Up to 3 key indicators that influenced the AI score (mirrors aiAnalysis.indicators)." aiAnalysis: type: object nullable: true additionalProperties: true description: "Full per-lead AI analysis row from saved_lead_lists.lead_analysis (score, reason, indicators, etc.)." # ----- Timestamps ----- createdAt: { type: string, format: date-time, nullable: true } updatedAt: { type: string, format: date-time, nullable: true } # ----- List context ----- listId: { type: string, nullable: true } listName: { type: string, nullable: true } listType: { type: string, nullable: true, example: "companies" } listCreatedAt: { type: string, format: date-time, nullable: true } listUpdatedAt: { type: string, format: date-time, nullable: true } sourceLocation: type: object nullable: true additionalProperties: true description: "Origin of the saved search (customer point, address, route etc.)." filtersApplied: type: object nullable: true additionalProperties: true description: "Filter snapshot the list was generated with." # ----- Raw passthrough ----- raw: type: object additionalProperties: true description: "Original lead row exactly as stored in saved_lead_lists.leads — use for any future fields not yet promoted to the typed schema above." CloseBuyLeadList: type: object required: [id, name, leads] properties: id: { type: string } name: { type: string } listType: { type: string, nullable: true, example: "companies" } leadCount: { type: integer } isMultiCustomer: { type: boolean } sourceLocation: { type: object, nullable: true, additionalProperties: true } filtersApplied: { type: object, nullable: true, additionalProperties: true } groupedCompanies: { type: object, nullable: true, additionalProperties: true } forLetters: type: boolean description: "True when the list is flagged for sending letters in Studio. Leads in such lists are background-enriched in the Mother app (website, pages, contacts) with fully automatic AI-driven choices." enrichmentStatus: type: string enum: [idle, running, completed, partial, failed] description: "Background-enrichment state for this list. `running` = still processing, `completed` = every lead succeeded, `partial` = some succeeded and some failed, `failed` = every lead failed, `idle` = never enriched." enrichmentStartedAt: { type: string, format: date-time, nullable: true } enrichmentCompletedAt: { type: string, format: date-time, nullable: true } enrichmentProgress: type: object nullable: true description: "Live counters updated while enrichment is running." properties: total: { type: integer } processed: { type: integer } succeeded: { type: integer } failed: { type: integer } skipped: { type: integer } createdAt: { type: string, format: date-time, nullable: true } updatedAt: { type: string, format: date-time, nullable: true } leads: type: array items: { $ref: "#/components/schemas/CloseBuyLead" } CloseBuyLeadsResponse: type: object required: [lists, leads] properties: lists: type: array items: { $ref: "#/components/schemas/CloseBuyLeadList" } leads: type: array items: { $ref: "#/components/schemas/CloseBuyLead" } description: "Deduplicated union of all leads across the returned lists." StudioSsoTokenPayload: type: object description: | Claims encoded in the HS256 JWT returned by `/api/v1/auth/password` and the SSO redirect. Signed with `CLOSEBUY_SSO_SECRET`, 5 min TTL, `iss=closebuy`, `aud=studio`. required: [sub, userId, email, orgId, role, iat, exp] properties: sub: { type: string, format: uuid } userId: { type: string, format: uuid } email: { type: string, format: email } name: { type: string } orgId: { type: string, nullable: true } role: type: string enum: [owner, manager, member, superadmin] iss: { type: string, example: "closebuy" } aud: { type: string, example: "studio" } iat: { type: integer } exp: { type: integer } PasswordLoginRequest: type: object required: [email, password] properties: email: { type: string, format: email } password: { type: string, format: password } organizationId: type: string nullable: true description: "Optional org to bind the token to. Defaults to the user's first membership." PasswordLoginResponse: type: object required: [token, expiresAt, user, role] properties: token: type: string description: "Signed JWT — same `StudioSsoTokenPayload` shape used by SSO." expiresAt: { type: string, format: date-time } user: type: object required: [id, email] properties: id: { type: string, format: uuid } email: { type: string, format: email } name: { type: string } orgId: { type: string, nullable: true } role: type: string enum: [owner, manager, member, superadmin] responses: NotFound: description: Not found content: application/json: schema: { $ref: "#/components/schemas/Error" } Unauthorized: description: Missing or invalid auth content: application/json: schema: { $ref: "#/components/schemas/Error" } paths: /api/v1/auth/password: post: operationId: passwordLogin summary: Exchange email + password for a Studio session JWT description: | Validates the user's Closebuy credentials and returns the same `StudioSsoTokenPayload` JWT minted by the SSO redirect flow. Use this when Studio runs its own native login form instead of redirecting through `/api/public/auth/sso`. - The bearer `CLOSEBUY_API_KEY` is still required on the request. - 5 failed attempts per (email, IP) within 15 minutes returns **429** with a `Retry-After` header (15 min lockout). - The JWT is signed with `CLOSEBUY_SSO_SECRET` (HS256), TTL 5 min. tags: [Auth] security: [{ MotherApiKey: [] }] requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/PasswordLoginRequest" } responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/PasswordLoginResponse" } "401": description: Invalid email or password (or invalid API key). content: application/json: schema: { $ref: "#/components/schemas/Error" } "403": description: User authenticated but is not a member of the requested org. content: application/json: schema: { $ref: "#/components/schemas/Error" } "422": description: Missing email/password. content: application/json: schema: { $ref: "#/components/schemas/Error" } "429": description: Too many failed attempts — locked out. headers: Retry-After: schema: { type: integer } description: Seconds until the lockout expires. content: application/json: schema: { $ref: "#/components/schemas/Error" } /api/v1/orgs: get: operationId: listOrgs summary: List organisations (optionally filter by user) description: | Without query params, returns every organisation visible to the API key. Pass `userId` (Closebuy user UUID) or `email` to scope the list to the organisations that user belongs to. Use this right after SSO to populate the org switcher for the authenticated user. **Superadmins:** if the resolved user has the `superadmin` role, the endpoint returns every organisation in the system regardless of membership. tags: [Orgs] security: [{ MotherApiKey: [] }] parameters: - in: query name: userId required: false schema: { type: string, format: uuid } - in: query name: email required: false schema: { type: string, format: email } responses: "200": description: OK content: application/json: schema: type: array items: { $ref: "#/components/schemas/CloseBuyOrg" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/orgs/{orgId}: get: operationId: getOrg summary: Fetch a single organisation (brand + sender) tags: [Orgs] security: [{ MotherApiKey: [] }] parameters: - in: path name: orgId required: true schema: { type: string } responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/CloseBuyOrg" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /api/v1/orgs/{orgId}/users: get: operationId: listOrgUsers summary: List users on an org (used to bootstrap Studio roles on SSO) tags: [Users] security: [{ MotherApiKey: [] }] parameters: - in: path name: orgId required: true schema: { type: string } responses: "200": description: OK content: application/json: schema: type: array items: { $ref: "#/components/schemas/CloseBuyUser" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/orgs/{orgId}/leads: get: operationId: listLeads summary: List leads + lead lists for an org description: | Returns every lead the mother app has saved for the org, hydrated with the full CVR/enrichment payload, the saved-list context (`listId`, `listName`, filters, source location), per-lead route data (`drivingDistanceKm`, `drivingDurationMin`) and the AI analysis row when present. By default the response is a flat, deduplicated `CloseBuyLead[]` for easy ingestion. Pass `includeLists=true` to receive the saved lists grouped under `lists` alongside the deduped `leads` union — useful for rebuilding the list UI in Studio. **P-unit dedup guarantee:** when a saved list contains both a production unit (`isProductionUnit=true`, populated `pNumber`) and its mother company sharing the same `cvrNumber`, the mother-company row is dropped from both the per-list `leads` array and the flat leads union. The P-unit is authoritative — its `pNumber`, address and coordinates describe the physical site Studio should send letters to, and `parentCompanyName` still exposes the legal entity name. tags: [Leads] security: [{ MotherApiKey: [] }] parameters: - in: path name: orgId required: true schema: { type: string } - in: query name: updatedSince schema: { type: string, format: date-time } description: "Optional incremental cursor against `saved_lead_lists.updated_at`." - in: query name: listId schema: { type: string } description: "Restrict the response to a single saved list." - in: query name: includeLists schema: { type: boolean, default: false } description: "When true, return `{ lists, leads }` instead of a flat array." responses: "200": description: OK content: application/json: schema: oneOf: - type: array items: { $ref: "#/components/schemas/CloseBuyLead" } - $ref: "#/components/schemas/CloseBuyLeadsResponse" "401": { $ref: "#/components/responses/Unauthorized" }