{"openapi":"3.1.0","info":{"title":"CM API","version":"1.0.0","description":"CM runs content marketing for a connected app: it publishes the blog,\ncaptures leads from it, and reports conversions to ad platforms.\n\nThere are two ways to integrate, and most subscribers need both.\n\n**Calling CM.** One registration endpoint, authenticated with the\nplatform key, which creates the app and returns every other\ncredential it will need. Conversion postbacks and lead capture are\nauthenticated per app with the credentials that call returns.\n\n**Being called by CM.** CM posts events outward as they happen — see\nthe `webhooks` section. Deliveries are retried with backoff and are\nreplayable, so a receiver that is briefly down does not cost the\nsubscriber a lead.\n\nCM deliberately does not know what a subscriber does with a lead.\nIt posts it and stops.\n"},"servers":[{"url":"https://cm-bot.io","description":"CM"}],"tags":[{"name":"Registration","description":"Creating and configuring a connected app"},{"name":"Leads","description":"Capturing someone who asked to hear from the app"},{"name":"Conversions","description":"Reporting outcomes back for ad attribution"}],"paths":{"/api/v1/connected-apps":{"post":{"operationId":"createConnectedApp","summary":"Register a connected app","tags":["Registration"],"security":[{"platformKey":[]}],"description":"Creates the app and returns every credential it needs. Idempotent on\n`domain`: calling it again with the same domain updates the existing\napp and returns 200 with the SAME secrets, so a re-registration on\ndeploy does not invalidate signatures already in use.\n\nDomains are compared normalised, because `https://x.com` and\n`https://x.com/` would otherwise become two apps for one site and\neach would get half the leads.\n\n`webhook_url` may hold several endpoints separated by whitespace or\ncommas. Each gets its own delivery and its own outcome.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConnectedAppInput"}}}},"responses":{"200":{"description":"Already registered; credentials unchanged","content":{"application/json":{"schema":{"type":"object","properties":{"connected_app":{"$ref":"#/components/schemas/ConnectedApp"}}}}}},"201":{"description":"Registered","content":{"application/json":{"schema":{"type":"object","properties":{"connected_app":{"$ref":"#/components/schemas/ConnectedApp"}}}}}},"400":{"description":"`connected_app` or `domain` missing","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or incorrect platform key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The app could not be saved","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/cm/conversions":{"post":{"operationId":"createConversion","summary":"Report a conversion","tags":["Conversions"],"security":[],"description":"Server-to-server postback telling CM that something worth attributing\nhappened, which CM forwards to the ad platform.\n\nAnswers on ANY host, not only cm-bot.io, so an app can post to its own\ndomain. Authenticated by the body rather than a header: send either\n`capi_token` (from registration) or `ref` (a first-party click token).\nBoth are unguessable; neither is a bearer credential.\n\nAccepted, not applied — 202 means CM has the conversion, not that the\nad platform has acknowledged it. `client_ip` and `client_user_agent`\ndefault to the values on the request when omitted.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversionInput"}}}},"responses":{"202":{"description":"Accepted for forwarding","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversionAccepted"}}}},"401":{"description":"Neither `capi_token` nor `ref` resolved to an app","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"The conversion was not valid","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/blog/subscribe":{"post":{"operationId":"createLead","summary":"Capture a lead","tags":["Leads"],"security":[],"description":"The blog's email-capture form. Unauthenticated by necessity — it is\nposted by a visitor's browser — and the app is resolved from\n`lead_magnet_slug`, `post_id`, or `post_slug` rather than from a\ncredential. One of the three is required. Prefer `post_id` (CM's\npost id, the value the webhook carries) — `post_slug` is lost if\nthe article is retitled between render and submit (D224).\n\nAnswers on ANY host, including a subscriber's own domain, because the\nform is rendered inside their blog.\n\nSend `Accept: application/json` for the shape below; the same endpoint\nanswers Turbo Stream and HTML for browser submissions.\n\nIdempotent per (app, email): submitting twice updates the existing\nsubscriber rather than creating a second one. It emits `lead.captured`\neach time, which the receiver deduplicates on `delivery_id`.\n\nA `phone` is optional and deliberately so — demanding one to read a\nblog post costs more leads than the faster follow-up wins back. The\nones who give it can be answered in seconds instead of by email.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LeadInput"}},"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/LeadInput"}}}},"responses":{"201":{"description":"Captured","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LeadAccepted"}}}},"422":{"description":"Invalid, or neither slug resolved to an app","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/blog/subscribe/unsubscribe/{token}":{"get":{"operationId":"unsubscribeLead","summary":"Unsubscribe a lead","tags":["Leads"],"security":[],"description":"The CAN-SPAM unsubscribe link in every nurture email. A page, not an\nAPI call, and documented because it is part of the compliance surface\na subscriber inherits: the token is unguessable and single-purpose, and\nfollowing the link must not require a login or a confirmation step.\n","parameters":[{"name":"token","in":"path","required":true,"schema":{"type":"string"},"description":"The subscriber's unsubscribe token, from the email link"}],"responses":{"200":{"description":"Unsubscribed","content":{"text/html":{}}},"404":{"description":"No subscriber for that token","content":{"text/html":{}}}}}},"/blog/lead-magnets/{slug}/download":{"get":{"operationId":"downloadLeadMagnet","summary":"Download a lead magnet","tags":["Leads"],"security":[],"description":"The download link in the day-zero nurture email. Redirects to a signed\nstorage URL. `token` is the subscriber's download token, a different\nsecret from unsubscribe (D244). Supplying it confirms the subscriber\nand counts the download; without it the file is still served but\nnothing is recorded. An unsubscribe token here is a 404.\n","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"},"description":"The lead magnet's slug"},{"name":"token","in":"query","required":false,"schema":{"type":"string"},"description":"The subscriber's download token, from the email link. Not the unsubscribe token."}],"responses":{"302":{"description":"Redirect to the signed file URL"},"404":{"description":"No such lead magnet, or the token does not name a subscriber","content":{"text/plain":{}}},"410":{"description":"The magnet exists but has no file attached","content":{"text/plain":{}}}}}}},"webhooks":{"lead.captured":{"post":{"operationId":"onLeadCaptured","summary":"Someone asked to hear from the app","description":"Sent to every URL in the app's `webhook_url` when a lead is\ncaptured. Retried with backoff on any non-2xx or network failure,\nabout five attempts over twenty minutes, then abandoned and\nreported. Answer 5xx to ask for a retry and 2xx to accept.\n\nDeduplicate on `delivery_id`, which is stable across retries — a\nredelivery is the same lead, not a second person with the same\naddress.\n\nHeaders: `X-Webhook-Event`, `X-Webhook-Delivery` (the same\n`delivery_id`), and `X-Webhook-Signature`, a hex HMAC-SHA256 of\nthe raw request body keyed with the app's `webhook_secret`.\nVerify it before trusting the body.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LeadCapturedEvent"}}}},"responses":{"200":{"description":"Accepted. CM will not send it again."},"5XX":{"description":"CM will retry with backoff."}}}},"blog_post_publish":{"post":{"operationId":"onBlogPostPublish","summary":"A post was published to the app's blog","description":"Sent when a post is published or republished. The receiver owns\nthe rendered article; CM does not serve it.\n\nSame sender as `lead.captured` (D222). Headers: `X-Webhook-Event`,\n`X-Webhook-Delivery` (the same `delivery_id`), and\n`X-Webhook-Signature`, a hex HMAC-SHA256 of the raw request body\nkeyed with the app's `webhook_secret`. Verify it before trusting\nthe body. `delivery_id` changes when the post is edited, which is\nwhat makes a republish distinguishable from a redelivery.\n\n`app.id` and `connected_app.id` are both the slug. Prefer\n`connected_app`; `app` is the same identifiers plus name and type.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BlogPostPublishEvent"}}}},"responses":{"200":{"description":"Accepted. CM will not send this delivery again."},"5XX":{"description":"CM will retry with backoff."}}}},"blog_post_delete":{"post":{"operationId":"onBlogPostDelete","summary":"A post was unpublished","description":"Sent when a post is unpublished. Same envelope and headers as\n`lead.captured` and `blog_post_publish` (D222): `delivery_id`,\n`occurred_at`, `connected_app`, plus `X-Webhook-Event`,\n`X-Webhook-Delivery` and `X-Webhook-Signature`. Deduplicate on\n`delivery_id`. Match the article on `post.id`.\n\n`app.id` is the slug, same as `connected_app.id`.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BlogPostDeleteEvent"}}}},"responses":{"200":{"description":"Accepted. CM will not send this delivery again."},"5XX":{"description":"CM will retry with backoff."}}}}},"components":{"securitySchemes":{"platformKey":{"type":"http","scheme":"bearer","description":"The platform registration key. It is the only credential CM issues\nthat is not tied to a single app: it may create and configure apps\nand can do nothing else. Not a per-app API key — do not ship it to\na browser or hand it to a subscriber.\n"}},"schemas":{"ConnectedAppInput":{"type":"object","required":["connected_app"],"properties":{"connected_app":{"type":"object","required":["domain"],"properties":{"domain":{"type":"string","description":"The app's site. Normalised: scheme added if missing, trailing slash removed."},"name":{"type":"string","description":"Defaults to the domain."},"app_type":{"type":"string","description":"Free-form label. Defaults to `custom`."},"webhook_url":{"type":"string","description":"Where to send events. Several may be given, whitespace or comma separated."}}}}},"ConnectedApp":{"type":"object","description":"Returned once, on registration. Store the secrets; CM will not show them again in a browser.","properties":{"id":{"type":"string","description":"The app's slug. This is the identifier in every webhook envelope."},"name":{"type":"string"},"domain":{"type":"string"},"app_type":{"type":"string"},"status":{"type":"string","enum":["active","inactive","pending"]},"webhook_url":{"type":["string","null"]},"webhook_secret":{"type":"string","description":"Key for the HMAC-SHA256 in `X-Webhook-Signature`."},"capi_token":{"type":"string","description":"Authenticates conversion postbacks."},"inbound_webhook_token":{"type":"string","description":"Bearer token for posting blog events INTO CM."}}},"ConversionInput":{"type":"object","description":"Send exactly one of `capi_token` or `ref`.","properties":{"capi_token":{"type":"string","description":"From registration."},"ref":{"type":"string","description":"A first-party click token, if the conversion came from a tracked link."},"event_name":{"type":"string","description":"e.g. `Purchase`, `Lead`."},"event_id":{"type":"string","description":"Your id for this event; used to deduplicate against the pixel."},"value":{"type":"number"},"currency":{"type":"string"},"email":{"type":"string","description":"Hashed before it leaves CM."},"phone":{"type":"string","description":"Hashed before it leaves CM."},"external_id":{"type":"string"},"fbc":{"type":"string"},"fbp":{"type":"string"},"client_ip":{"type":"string","description":"Defaults to the requesting IP."},"client_user_agent":{"type":"string","description":"Defaults to the request's User-Agent."},"event_source_url":{"type":"string"},"event_time":{"type":"integer","description":"Unix seconds. Defaults to now."}}},"ConversionAccepted":{"type":"object","properties":{"ok":{"type":"boolean"},"id":{"type":"integer","description":"CM's id for the conversion."},"status":{"type":["string","null"],"description":"Forwarding status at the ad platform, if known yet."}}},"LeadInput":{"type":"object","description":"One of `lead_magnet_slug`, `post_id`, or `post_slug` is required; it is what identifies the app.","properties":{"lead_magnet_slug":{"type":"string"},"post_id":{"type":"integer","description":"CM's post id. Survives a retitle. Prefer this over `post_slug`."},"post_slug":{"type":"string","description":"The article's current slug. Ignored when `post_id` is present."},"cm_blog_subscriber":{"type":"object","required":["email"],"properties":{"email":{"type":"string"},"name":{"type":"string"},"phone":{"type":"string","description":"Optional. Its presence is what makes an SMS follow-up possible."}}},"utm_source":{"type":"string"},"utm_medium":{"type":"string"},"utm_campaign":{"type":"string"},"utm_term":{"type":"string"},"utm_content":{"type":"string"},"location":{"type":"string","description":"Which form on the page. Presentation only."}}},"LeadAccepted":{"type":"object","properties":{"ok":{"type":"boolean"},"id":{"type":"integer"}}},"LeadCapturedEvent":{"type":"object","required":["event","delivery_id","occurred_at","connected_app","lead"],"properties":{"event":{"type":"string","enum":["lead.captured"]},"delivery_id":{"type":"string","description":"Stable across retries. Deduplicate on this."},"occurred_at":{"type":"string","format":"date-time"},"connected_app":{"type":"object","properties":{"id":{"type":"string","description":"The app's slug."},"domain":{"type":"string"}}},"lead":{"type":"object","properties":{"id":{"type":"integer","description":"CM's id for the lead."},"email":{"type":"string"},"name":{"type":["string","null"]},"phone":{"type":["string","null"],"description":"Null unless the visitor volunteered it. No SMS consent is implied by its presence."},"lead_magnet":{"type":["string","null"],"description":"Slug of what they downloaded, if anything."},"source_post":{"type":["string","null"],"description":"Slug of the post they came from, if any."},"utm_source":{"type":["string","null"]},"utm_medium":{"type":["string","null"]},"utm_campaign":{"type":["string","null"]}}}}},"BlogPostPublishEvent":{"type":"object","required":["event","delivery_id","occurred_at","connected_app","post","app"],"properties":{"event":{"type":"string","enum":["blog_post_publish"]},"delivery_id":{"type":"string","description":"Stable across retries. Deduplicate on this."},"occurred_at":{"type":"string","format":"date-time"},"connected_app":{"type":"object","properties":{"id":{"type":"string","description":"The app's slug."},"domain":{"type":"string"}}},"post":{"type":"object","properties":{"id":{"type":"integer","description":"CM's post id. Store it; it is how a later delete is matched."},"slug":{"type":"string"},"title":{"type":"string"},"content":{"type":"string","description":"Rendered HTML."},"excerpt":{"type":["string","null"]},"meta_description":{"type":["string","null"]},"jsonld":{"type":["object","null"],"description":"Structured data to emit on the article page."},"author":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time"},"published_at":{"type":"string","format":"date-time"},"target_platforms":{"type":"array","items":{"type":"string"}},"media_urls":{"type":"array","items":{"type":"string"},"description":"Permanent public URLs on CM's storage (`amazon_public`). The receiver does not own these and is not expected to copy them (D223)."},"tags":{"type":"array","items":{"type":"string"}},"lead_magnet":{"type":["object","null"],"description":"The magnet to offer on this article, if any."}}},"app":{"type":"object","description":"Same identifiers as `connected_app`, plus name and type. `id` is the slug.","properties":{"id":{"type":"string","description":"The app's slug. Same value as `connected_app.id`."},"name":{"type":"string"},"domain":{"type":"string"},"type":{"type":["string","null"]}}}}},"BlogPostDeleteEvent":{"type":"object","required":["event","delivery_id","occurred_at","connected_app","post","app"],"properties":{"event":{"type":"string","enum":["blog_post_delete"]},"delivery_id":{"type":"string","description":"Stable across retries. Deduplicate on this."},"occurred_at":{"type":"string","format":"date-time"},"connected_app":{"type":"object","properties":{"id":{"type":"string","description":"The app's slug."},"domain":{"type":"string"}}},"post":{"type":"object","properties":{"id":{"type":"integer","description":"Match the published post on this."},"slug":{"type":"string"},"platform_post_id":{"type":["string","null"],"description":"The id the receiver returned when publishing."}}},"app":{"type":"object","description":"Same identifiers as `connected_app`, plus name and type. `id` is the slug.","properties":{"id":{"type":"string","description":"The app's slug. Same value as `connected_app.id`."},"name":{"type":"string"},"domain":{"type":"string"},"type":{"type":["string","null"]}}}}},"Error":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["unauthorized","not_found","bad_request","validation_failed"]},"message":{"type":"string"}}}}}}}}