{
  "openapi": "3.1.0",
  "info": {
    "title": "Remindlo REST API",
    "version": "1.0.0",
    "summary": "Contacts, campaign enrolment, one-off SMS and webhooks for Remindlo.",
    "description": "Remindlo sends automated SMS and email reminders for service businesses so customers return for recurring work and stop no-showing.\n\nThis API lets an external system (a CRM, a booking tool, an AI agent) keep the contact list in sync, enrol contacts in reminder campaigns, send one-off messages and subscribe to change events.\n\n**Authentication.** Every `/v1/*` endpoint takes an API key in the `x-api-key` header. Create one in the dashboard under Settings -> API Keys. The key is scoped to a single tenant; there is no account or tenant identifier in any request path or body.\n\n**Phone numbers** are E.164 throughout (`+447912345678`). Requests carry the field as `phone`; responses echo it as `phone`, though the underlying column is `phone_e164`.\n\n**Response envelope.** Successful responses carry `\"success\": true` alongside the payload. Errors carry `\"success\": false` and an `error` object with a stable machine-readable `code`. Two endpoints predate that convention and are documented individually: `POST /v1/campaigns-enroll` returns a bare result object, and `POST /oauth/token` follows RFC 6749.\n\n**Rate limits.** There is no documented per-key rate limit, and no `RateLimit-*` headers are returned, so there is nothing to self-throttle against. Cloudflare sits in front of the API and may reject abusive traffic at the edge. Treat any `429` as a signal to back off, and keep your own request rate reasonable. `SMS_LIMIT_EXCEEDED` on `/v1/messages` is a billing quota, not a rate limit.",
    "termsOfService": "https://www.remindlo.co.uk/terms",
    "contact": {
      "name": "Remindlo support",
      "email": "support@remindlo.co.uk",
      "url": "https://www.remindlo.co.uk/help/sms-reminder-api"
    },
    "license": { "name": "Proprietary", "url": "https://www.remindlo.co.uk/terms" }
  },
  "externalDocs": {
    "description": "Full REST API guide with runnable examples in curl, JavaScript, Python and PHP",
    "url": "https://www.remindlo.co.uk/help/sms-reminder-api"
  },
  "servers": [
    { "url": "https://api.remindlo.co.uk", "description": "Production" }
  ],
  "security": [{ "apiKey": [] }],
  "tags": [
    { "name": "Contacts", "description": "Create, update, look up and list the people who receive reminders." },
    { "name": "Campaigns", "description": "List reminder campaigns and enrol contacts in them." },
    { "name": "Messages", "description": "Send a one-off SMS outside of any campaign schedule." },
    { "name": "Webhooks", "description": "Subscribe to contact, campaign and message events." },
    { "name": "OAuth", "description": "Authorisation-code exchange used by the Claude.ai MCP connector." }
  ],
  "paths": {
    "/v1/contacts": {
      "post": {
        "operationId": "upsertContact",
        "tags": ["Contacts"],
        "summary": "Create or update a contact",
        "description": "Creates a contact, or updates the existing one when the phone number or email already belongs to a contact in this tenant. Safe to retry: the same payload converges on the same contact rather than creating duplicates.\n\nSupply at least one of `phone` or `email`. Pass `campaign_ids` to enrol the contact in one or more campaigns in the same call; the per-campaign outcome comes back in `enrollments`.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContactUpsertRequest" } } }
        },
        "responses": {
          "200": {
            "description": "Contact created or updated.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContactUpsertResponse" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      },
      "get": {
        "operationId": "listContacts",
        "tags": ["Contacts"],
        "summary": "List contacts",
        "description": "Returns a paginated list of contacts, newest first by default.\n\nThis path doubles as a lookup: if any of `id`, `phone`, `phone_e164` or `email` is supplied, the request is routed to the single-contact endpoint instead and the response is a `contact` object rather than a `contacts` array. Use `GET /v1/contacts/{contact_id}` when you already have an id and want the single-contact shape unambiguously.",
        "parameters": [
          { "name": "limit", "in": "query", "description": "Maximum contacts to return.", "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 } },
          { "name": "offset", "in": "query", "description": "Number of contacts to skip, for paging.", "schema": { "type": "integer", "minimum": 0, "default": 0 } },
          { "name": "search", "in": "query", "description": "Free-text match against first name, last name, phone and email.", "schema": { "type": "string" } },
          { "name": "has_phone", "in": "query", "description": "Restrict to contacts that have a phone number (the ones that can receive SMS).", "schema": { "type": "boolean" } },
          { "name": "has_email", "in": "query", "description": "Restrict to contacts that have an email address.", "schema": { "type": "boolean" } },
          { "name": "marketing_consent", "in": "query", "description": "Filter by whether the contact consented to receive messages.", "schema": { "type": "boolean" } },
          { "name": "is_recurrent", "in": "query", "description": "Filter by whether the contact is on a recurring service cycle.", "schema": { "type": "boolean" } },
          { "name": "next_due_before", "in": "query", "description": "Only contacts whose next service is due before this instant.", "schema": { "type": "string", "format": "date-time" } },
          { "name": "next_due_after", "in": "query", "description": "Only contacts whose next service is due after this instant.", "schema": { "type": "string", "format": "date-time" } },
          { "name": "created_after", "in": "query", "description": "Only contacts created after this instant.", "schema": { "type": "string", "format": "date-time" } },
          { "name": "tags", "in": "query", "description": "Comma-separated tags; returns contacts carrying any of them (e.g. tags=vip,premium).", "schema": { "type": "string" }, "example": "vip,premium" },
          { "name": "sort_by", "in": "query", "description": "Field to sort by.", "schema": { "type": "string", "enum": ["created_at", "updated_at", "next_due_at", "first_name"], "default": "created_at" } },
          { "name": "sort_order", "in": "query", "description": "Sort direction.", "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } },
          { "name": "id", "in": "query", "description": "Look up one contact by id. Switches the response to the single-contact shape.", "schema": { "type": "string", "format": "uuid" } },
          { "name": "phone", "in": "query", "description": "Look up one contact by E.164 phone number. Switches the response to the single-contact shape.", "schema": { "type": "string", "pattern": "^\\+[1-9]\\d{6,14}$" } },
          { "name": "phone_e164", "in": "query", "description": "Compatibility alias for `phone`, accepted because the underlying column is named phone_e164. Same effect; prefer `phone` in new code. If both are sent, `phone` wins.", "schema": { "type": "string", "pattern": "^\\+[1-9]\\d{6,14}$" } },
          { "name": "email", "in": "query", "description": "Look up one contact by email. Switches the response to the single-contact shape.", "schema": { "type": "string", "format": "email" } }
        ],
        "responses": {
          "200": {
            "description": "A page of contacts, or a single contact when a lookup parameter was supplied.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    { "$ref": "#/components/schemas/ContactListResponse" },
                    { "$ref": "#/components/schemas/ContactGetResponse" }
                  ]
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/v1/contacts/{contact_id}": {
      "get": {
        "operationId": "getContact",
        "tags": ["Contacts"],
        "summary": "Get one contact by id",
        "description": "Returns a single contact with its full field set, including notes, custom fields and the campaigns it is enrolled in. This is the richest contact representation the API offers; the list endpoint returns a trimmed subset.",
        "parameters": [
          { "name": "contact_id", "in": "path", "required": true, "description": "The contact's UUID.", "schema": { "type": "string", "format": "uuid" } }
        ],
        "responses": {
          "200": {
            "description": "The contact.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContactGetResponse" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/v1/campaigns": {
      "get": {
        "operationId": "listCampaigns",
        "tags": ["Campaigns"],
        "summary": "List SMS campaigns",
        "description": "Returns every campaign in this tenant that can send SMS. Campaigns restricted to other channels are filtered out.\n\nCall this first when you need a `campaign_id` for `upsertContact` or `enrollContact`.",
        "responses": {
          "200": {
            "description": "The tenant's SMS campaigns.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CampaignListResponse" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/v1/campaigns-enroll": {
      "post": {
        "operationId": "enrollContact",
        "tags": ["Campaigns"],
        "summary": "Enrol an existing contact in a campaign",
        "description": "Adds a contact that already exists to a campaign. For a running one-off campaign the message is queued straight away; for a recurring campaign the scheduler decides when to send.\n\nEnrolling twice is harmless: the second call reports `already_enrolled` rather than queueing a second message.\n\nThis endpoint predates the standard envelope and returns a bare result object with no `success` field.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnrollRequest" } } }
        },
        "responses": {
          "200": {
            "description": "Enrolment outcome. Note that `skipped` is also reported here with HTTP 200.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnrollResponse" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/v1/messages": {
      "post": {
        "operationId": "sendMessage",
        "tags": ["Messages"],
        "summary": "Send a one-off SMS",
        "description": "Queues a single SMS to an existing contact, outside any campaign schedule. Requires a paid plan.\n\n**Not idempotent.** Every accepted call consumes SMS quota and bills another segment, so do not retry blindly on a timeout; look the message up before sending again. Long messages are split into segments and each segment counts separately (160 GSM-7 characters for the first segment, 70 if the text needs Unicode).",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SendMessageRequest" } } }
        },
        "responses": {
          "202": {
            "description": "Message accepted and queued for delivery. Delivery itself is asynchronous.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SendMessageResponse" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": {
            "description": "Sending one-off messages requires a paid plan (`PLAN_REQUIRED`).",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": {
            "description": "The tenant's SMS quota is exhausted (`SMS_LIMIT_EXCEEDED`).",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/v1/webhooks": {
      "get": {
        "operationId": "listWebhookEndpoints",
        "tags": ["Webhooks"],
        "summary": "List webhook endpoints",
        "description": "Returns every webhook endpoint registered for this tenant, including ones created in the dashboard or by the Zapier integration. Signing secrets are never returned here.",
        "responses": {
          "200": {
            "description": "The tenant's webhook endpoints.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookEndpointListResponse" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      },
      "post": {
        "operationId": "createWebhookEndpoint",
        "tags": ["Webhooks"],
        "summary": "Create a webhook endpoint",
        "description": "Registers an HTTPS URL to receive event deliveries.\n\nThe response contains `signing_secret`, and that is the only time it is ever returned. Store it before discarding the response; it is what you need to verify the `X-Remindlo-Signature` header on every future delivery.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookCreateRequest" } } }
        },
        "responses": {
          "200": {
            "description": "Endpoint created. Contains the one-and-only copy of the signing secret.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookCreateResponse" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/v1/webhooks/delete": {
      "post": {
        "operationId": "deleteWebhookEndpoint",
        "tags": ["Webhooks"],
        "summary": "Delete a webhook endpoint",
        "description": "Permanently removes an endpoint and its delivery history. Modelled as POST rather than DELETE so it can be called from clients restricted to GET and POST.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookIdRequest" } } }
        },
        "responses": {
          "200": {
            "description": "Endpoint deleted.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookDeletedResponse" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/v1/webhooks/subscribe": {
      "post": {
        "operationId": "subscribeWebhook",
        "tags": ["Webhooks"],
        "summary": "Subscribe a webhook (integration clients)",
        "description": "Creates a webhook endpoint tagged as integration-managed and returns only its id. Intended for Zapier and comparable platforms that manage their own subscription lifecycle.\n\nFor your own integrations prefer `createWebhookEndpoint`, which also returns the signing secret needed to verify deliveries.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookSubscribeRequest" } } }
        },
        "responses": {
          "200": {
            "description": "Subscription created.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookSubscribeResponse" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/v1/webhooks/unsubscribe": {
      "post": {
        "operationId": "unsubscribeWebhook",
        "tags": ["Webhooks"],
        "summary": "Unsubscribe a webhook (integration clients)",
        "description": "Deletes an integration-managed endpoint. Only endpoints created through `subscribeWebhook` can be removed this way; anything created in the dashboard or via `createWebhookEndpoint` is rejected, and must go through `deleteWebhookEndpoint` instead.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookIdRequest" } } }
        },
        "responses": {
          "200": {
            "description": "Subscription removed.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookDeletedResponse" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/oauth/token": {
      "post": {
        "operationId": "exchangeAuthorizationCode",
        "tags": ["OAuth"],
        "summary": "Exchange an authorisation code for an API key",
        "description": "RFC 6749 authorisation-code grant, used by the Claude.ai custom connector when a user authorises the Remindlo MCP server. Not part of the normal REST flow: ordinary integrations use a dashboard-issued `x-api-key` and never call this.\n\nThe returned `access_token` is a Remindlo API key. Despite `expires_in`, the key stays valid until it is revoked in the dashboard.\n\nThis endpoint is unauthenticated and accepts either JSON or `application/x-www-form-urlencoded`. Errors follow RFC 6749, not the `success`/`error` envelope used elsewhere.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/TokenRequest" } },
            "application/json": { "schema": { "$ref": "#/components/schemas/TokenRequest" } }
          }
        },
        "responses": {
          "200": {
            "description": "Token issued.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TokenResponse" } } }
          },
          "400": {
            "description": "RFC 6749 error, e.g. `invalid_grant`, `invalid_request`, `unsupported_grant_type`, `invalid_client`.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthErrorResponse" } } }
          },
          "500": {
            "description": "`server_error`.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthErrorResponse" } } }
          }
        }
      }
    }
  },
  "webhooks": {
    "contact.created": {
      "post": {
        "operationId": "onContactCreated",
        "summary": "A contact was added",
        "description": "Delivered when a new contact appears in the tenant, whatever created it: the API, a CSV import, calendar sync or the dashboard.",
        "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookEnvelope" } } } },
        "responses": { "200": { "description": "Return any 2xx to acknowledge. Non-2xx and timeouts are retried with exponential backoff (1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h; up to 8 attempts), after which a persistently failing endpoint is disabled." } }
      }
    },
    "contact.updated": {
      "post": {
        "operationId": "onContactUpdated",
        "summary": "A contact's details changed",
        "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookEnvelope" } } } },
        "responses": { "200": { "description": "Acknowledged." } }
      }
    },
    "contact.deleted": {
      "post": {
        "operationId": "onContactDeleted",
        "summary": "A contact was removed",
        "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookEnvelope" } } } },
        "responses": { "200": { "description": "Acknowledged." } }
      }
    },
    "campaign.created": {
      "post": {
        "operationId": "onCampaignCreated",
        "summary": "A campaign was created",
        "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookEnvelope" } } } },
        "responses": { "200": { "description": "Acknowledged." } }
      }
    },
    "campaign.enrolled": {
      "post": {
        "operationId": "onCampaignEnrolled",
        "summary": "A contact was enrolled in a campaign",
        "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookEnvelope" } } } },
        "responses": { "200": { "description": "Acknowledged." } }
      }
    },
    "message.sent": {
      "post": {
        "operationId": "onMessageSent",
        "summary": "An SMS was handed to the carrier",
        "description": "Fired when the message is accepted by the SMS provider, which is not the same as confirmed handset delivery.",
        "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookEnvelope" } } } },
        "responses": { "200": { "description": "Acknowledged." } }
      }
    },
    "message.failed": {
      "post": {
        "operationId": "onMessageFailed",
        "summary": "An SMS failed to send",
        "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookEnvelope" } } } },
        "responses": { "200": { "description": "Acknowledged." } }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "apiKey",
        "in": "header",
        "name": "x-api-key",
        "description": "A tenant-scoped API key, created in the dashboard under Settings -> API Keys. Live keys are prefixed `sk_live_`. Send it on every `/v1/*` request; never expose it in client-side code."
      }
    },
    "schemas": {
      "ContactUpsertRequest": {
        "type": "object",
        "description": "At least one of `phone` or `email` must be present; that field is also what an existing contact is matched on.",
        "anyOf": [{ "required": ["phone"] }, { "required": ["email"] }],
        "properties": {
          "phone": { "type": "string", "pattern": "^\\+[1-9]\\d{6,14}$", "description": "Phone number in E.164 format, e.g. +447912345678. Rejected with INVALID_PHONE_FORMAT if it lacks the leading + and country code.", "examples": ["+447912345678"] },
          "email": { "type": "string", "format": "email", "description": "Email address. Contacts with an email but no phone can still receive email reminders." },
          "first_name": { "type": "string", "description": "Given name. Used by the {{first_name}} template variable." },
          "last_name": { "type": "string", "description": "Family name." },
          "marketing_consent": { "type": "boolean", "description": "Whether the contact agreed to receive messages. Shown in the dashboard as 'Consent to receive messages'. Setting it true stamps the consent time automatically." },
          "next_due_at": { "type": "string", "format": "date-time", "description": "When the contact is next due for service. Pre-visit reminders are scheduled backwards from this instant." },
          "last_service_at": { "type": "string", "format": "date-time", "description": "When the contact was last served. Post-visit and reactivation reminders are scheduled forwards from this instant." },
          "note": { "type": "string", "description": "Free-text note, visible in the dashboard only." },
          "tags": { "type": "array", "items": { "type": "string" }, "description": "Labels for segmentation, e.g. [\"VIP\"]." },
          "custom_fields": { "type": "object", "additionalProperties": true, "description": "Arbitrary JSON. Readable in templates as {{custom.field_name}}." },
          "campaign_ids": { "type": "array", "items": { "type": "string", "format": "uuid" }, "description": "Campaigns to enrol this contact in as part of the same call. Get ids from listCampaigns." },
          "is_recurrent": { "type": "boolean", "description": "Marks the contact as a recurring service. A nightly job then rolls next_due_at forward by the interval each time the due date passes, so the schedule never drifts." },
          "recurrent_interval_value": { "type": "integer", "minimum": 1, "maximum": 999, "description": "How many units between services. Required when is_recurrent is true." },
          "recurrent_interval_unit": { "type": "string", "enum": ["days", "months", "years"], "description": "Unit for recurrent_interval_value. Required when is_recurrent is true." }
        },
        "additionalProperties": false
      },
      "Contact": {
        "type": "object",
        "description": "The contact representation returned by upsert and list.",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "first_name": { "type": ["string", "null"] },
          "last_name": { "type": ["string", "null"] },
          "phone": { "type": ["string", "null"], "description": "E.164 phone number." },
          "email": { "type": ["string", "null"] },
          "marketing_consent": { "type": "boolean" },
          "next_due_at": { "type": ["string", "null"], "format": "date-time" },
          "tags": { "type": ["array", "null"], "items": { "type": "string" } },
          "is_recurrent": { "type": "boolean" },
          "recurrent_interval_value": { "type": ["integer", "null"] },
          "recurrent_interval_unit": { "type": ["string", "null"], "enum": ["days", "months", "years", null] },
          "created_at": { "type": "string", "format": "date-time" },
          "updated_at": { "type": "string", "format": "date-time" }
        },
        "required": ["id", "phone", "email", "marketing_consent", "created_at"]
      },
      "ContactDetail": {
        "allOf": [
          { "$ref": "#/components/schemas/Contact" },
          {
            "type": "object",
            "description": "Extra fields returned only by the single-contact endpoint.",
            "properties": {
              "marketing_consent_at": { "type": ["string", "null"], "format": "date-time", "description": "When the current consent answer was recorded, in either direction. Null only if consent has never been set on this contact." },
              "last_service_at": { "type": ["string", "null"], "format": "date-time" },
              "note": { "type": ["string", "null"] },
              "custom_fields": { "type": ["object", "null"], "additionalProperties": true },
              "campaigns": {
                "type": "array",
                "description": "Campaigns this contact is enrolled in.",
                "items": {
                  "type": "object",
                  "properties": {
                    "campaign_id": { "type": "string", "format": "uuid" },
                    "campaign_name": { "type": "string", "description": "Falls back to \"Unknown\" if the campaign has since been deleted." }
                  },
                  "required": ["campaign_id", "campaign_name"]
                }
              }
            }
          }
        ]
      },
      "ContactUpsertResponse": {
        "type": "object",
        "properties": {
          "success": { "const": true },
          "contact_id": { "type": "string", "format": "uuid" },
          "action": { "type": "string", "enum": ["created", "updated"], "description": "Whether this call created a new contact or matched an existing one." },
          "contact": { "$ref": "#/components/schemas/Contact" },
          "enrollments": {
            "type": "array",
            "description": "Present only when campaign_ids was supplied. One entry per requested campaign.",
            "items": { "$ref": "#/components/schemas/EnrollResponse" }
          },
          "visit_materialisation": { "type": "object", "additionalProperties": true, "description": "Present only when the upsert also materialised a visit from a recurring rule." }
        },
        "required": ["success", "contact_id", "action", "contact"]
      },
      "ContactListResponse": {
        "type": "object",
        "properties": {
          "success": { "const": true },
          "contacts": { "type": "array", "items": { "$ref": "#/components/schemas/Contact" } },
          "pagination": {
            "type": "object",
            "properties": {
              "total": { "type": "integer", "description": "Total contacts matching the filters, ignoring limit and offset." },
              "limit": { "type": "integer" },
              "offset": { "type": "integer" },
              "has_more": { "type": "boolean", "description": "True when further pages exist." }
            },
            "required": ["total", "limit", "offset", "has_more"]
          }
        },
        "required": ["success", "contacts", "pagination"]
      },
      "ContactGetResponse": {
        "type": "object",
        "properties": {
          "success": { "const": true },
          "contact": { "$ref": "#/components/schemas/ContactDetail" }
        },
        "required": ["success", "contact"]
      },
      "Campaign": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "name": { "type": "string" },
          "type": { "type": ["string", "null"], "description": "Campaign type, e.g. recurring or one_time." },
          "channels": { "type": ["array", "null"], "items": { "type": "string" }, "description": "Delivery channels. Null means SMS, for campaigns created before channels existed." },
          "status": { "type": "string", "description": "Lifecycle state, e.g. draft, running, paused. Only a running campaign sends anything." },
          "auto_enroll": { "type": "boolean", "description": "Whether contacts arriving from calendar sync are enrolled in this campaign automatically." },
          "created_at": { "type": "string", "format": "date-time" }
        },
        "required": ["id", "name", "status"]
      },
      "CampaignListResponse": {
        "type": "object",
        "properties": {
          "success": { "const": true },
          "campaigns": { "type": "array", "items": { "$ref": "#/components/schemas/Campaign" } }
        },
        "required": ["success", "campaigns"]
      },
      "EnrollRequest": {
        "type": "object",
        "required": ["campaign_id"],
        "properties": {
          "campaign_id": { "type": "string", "format": "uuid", "description": "The campaign to enrol into." },
          "contact_id": { "type": "string", "format": "uuid", "description": "The contact to enrol. Preferred over customer_id." },
          "customer_id": { "type": "string", "format": "uuid", "description": "Deprecated alias for contact_id, still accepted. If both are sent, contact_id wins." },
          "appointment_id": { "type": "string", "format": "uuid", "description": "Optional appointment to tie the enrolment to." }
        },
        "anyOf": [{ "required": ["contact_id"] }, { "required": ["customer_id"] }],
        "additionalProperties": false
      },
      "EnrollResponse": {
        "type": "object",
        "description": "Returned bare, without a success field.",
        "properties": {
          "campaign_id": { "type": "string", "format": "uuid", "description": "Present when this result is an element of an upsert's enrollments array." },
          "enrollment_id": { "type": ["string", "null"], "format": "uuid", "description": "Null when nothing was queued, which is normal for a recurring campaign whose send the scheduler still owns." },
          "status": { "type": "string", "enum": ["enrolled", "already_enrolled", "skipped"] },
          "reason": { "type": "string", "description": "Why the enrolment was skipped or deferred, e.g. campaign_not_active, will_be_processed_by_scheduler." }
        },
        "required": ["status"]
      },
      "SendMessageRequest": {
        "type": "object",
        "required": ["contact_id", "body"],
        "properties": {
          "contact_id": { "type": "string", "format": "uuid", "description": "The contact to message. Must already exist and have a phone number." },
          "body": { "type": "string", "minLength": 1, "maxLength": 1600, "description": "The message text. Counts against SMS quota in segments, not messages." },
          "channel": { "type": "string", "enum": ["sms"], "default": "sms", "description": "Delivery channel. Only sms is supported today." }
        },
        "additionalProperties": false
      },
      "SendMessageResponse": {
        "type": "object",
        "properties": {
          "success": { "const": true },
          "message_id": { "type": "string", "format": "uuid", "description": "Use this to correlate the later message.sent or message.failed webhook." },
          "status": { "type": "string", "enum": ["queued"] },
          "contact_id": { "type": "string", "format": "uuid" },
          "parts": { "type": "integer", "description": "SMS segments this message was split into. This is the number billed." },
          "channel": { "type": "string", "enum": ["sms"] }
        },
        "required": ["success", "message_id", "status", "contact_id", "parts", "channel"]
      },
      "WebhookEventType": {
        "type": "string",
        "enum": ["contact.created", "contact.updated", "contact.deleted", "campaign.created", "campaign.enrolled", "message.sent", "message.failed"]
      },
      "WebhookEndpoint": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "name": { "type": "string" },
          "target_url": { "type": "string", "format": "uri" },
          "source": { "type": "string", "description": "Who created the endpoint, e.g. manual or zapier. Only zapier-sourced endpoints can be removed via unsubscribeWebhook." },
          "status": { "type": "string", "description": "active, or disabled after repeated permanent delivery failures." },
          "event_types": { "type": "array", "items": { "$ref": "#/components/schemas/WebhookEventType" } },
          "created_at": { "type": "string", "format": "date-time" },
          "updated_at": { "type": "string", "format": "date-time" }
        },
        "required": ["id", "name", "target_url", "status", "event_types"]
      },
      "WebhookEndpointListResponse": {
        "type": "object",
        "properties": {
          "success": { "const": true },
          "endpoints": { "type": "array", "items": { "$ref": "#/components/schemas/WebhookEndpoint" } }
        },
        "required": ["success", "endpoints"]
      },
      "WebhookCreateRequest": {
        "type": "object",
        "required": ["name", "target_url", "event_types"],
        "properties": {
          "name": { "type": "string", "description": "A label to recognise this endpoint by in the dashboard." },
          "target_url": { "type": "string", "format": "uri", "description": "HTTPS URL that will receive deliveries. Private and loopback addresses are rejected." },
          "event_types": { "type": "array", "minItems": 1, "items": { "$ref": "#/components/schemas/WebhookEventType" }, "description": "Events to subscribe to." }
        },
        "additionalProperties": false
      },
      "WebhookCreateResponse": {
        "type": "object",
        "properties": {
          "success": { "const": true },
          "endpoint": { "$ref": "#/components/schemas/WebhookEndpoint" },
          "signing_secret": { "type": "string", "description": "Prefixed whsec_. Returned once and never again; used to verify the X-Remindlo-Signature header." }
        },
        "required": ["success", "endpoint", "signing_secret"]
      },
      "WebhookSubscribeRequest": {
        "type": "object",
        "required": ["target_url", "event_types"],
        "properties": {
          "target_url": { "type": "string", "format": "uri" },
          "event_types": { "type": "array", "minItems": 1, "items": { "$ref": "#/components/schemas/WebhookEventType" } }
        },
        "additionalProperties": false
      },
      "WebhookSubscribeResponse": {
        "type": "object",
        "properties": {
          "success": { "const": true },
          "endpoint_id": { "type": "string", "format": "uuid" }
        },
        "required": ["success", "endpoint_id"]
      },
      "WebhookIdRequest": {
        "type": "object",
        "required": ["endpoint_id"],
        "properties": { "endpoint_id": { "type": "string", "format": "uuid" } },
        "additionalProperties": false
      },
      "WebhookDeletedResponse": {
        "type": "object",
        "properties": {
          "success": { "const": true },
          "deleted": { "const": true }
        },
        "required": ["success", "deleted"]
      },
      "WebhookEnvelope": {
        "type": "object",
        "description": "Every delivery uses this envelope. Verify the X-Remindlo-Signature header (format `t={unix},v1={hex}`) by computing HMAC-SHA256 of `{t}.{raw body}` with your signing secret before trusting the payload.",
        "properties": {
          "id": { "type": "string", "format": "uuid", "description": "Delivery id. Stable across retries, so use it to deduplicate." },
          "type": { "$ref": "#/components/schemas/WebhookEventType" },
          "created_at": { "type": "string", "format": "date-time" },
          "api_version": { "type": "string", "examples": ["2026-03-25"] },
          "data": { "type": "object", "additionalProperties": true, "description": "The affected resource. Shape depends on type; contact events carry the contact, with the phone number under phone_e164." }
        },
        "required": ["id", "type", "created_at", "data"]
      },
      "TokenRequest": {
        "type": "object",
        "required": ["grant_type", "code", "redirect_uri"],
        "properties": {
          "grant_type": { "type": "string", "enum": ["authorization_code"] },
          "code": { "type": "string", "description": "The authorisation code from the consent redirect. Single use and short-lived." },
          "redirect_uri": { "type": "string", "format": "uri", "description": "Must match the redirect_uri the code was issued for." },
          "client_id": { "type": "string" },
          "client_secret": { "type": "string", "description": "Required only for clients registered as confidential." }
        }
      },
      "TokenResponse": {
        "type": "object",
        "properties": {
          "access_token": { "type": "string", "description": "A Remindlo API key. Send it as `x-api-key` on REST calls, or as a Bearer token to the MCP server." },
          "token_type": { "const": "Bearer" },
          "expires_in": { "type": "integer", "description": "Advertised lifetime in seconds. The key itself remains valid until revoked in the dashboard." },
          "scope": { "type": "string" }
        },
        "required": ["access_token", "token_type"]
      },
      "OAuthErrorResponse": {
        "type": "object",
        "properties": {
          "error": { "type": "string", "enum": ["invalid_request", "invalid_client", "invalid_grant", "unsupported_grant_type", "server_error"] },
          "error_description": { "type": "string" }
        },
        "required": ["error"]
      },
      "ErrorResponse": {
        "type": "object",
        "properties": {
          "success": { "const": false },
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "string",
                "description": "Stable machine-readable code. Branch on this, not on the message.",
                "enum": ["INVALID_PHONE_FORMAT", "INVALID_EMAIL_FORMAT", "MISSING_IDENTIFIER", "MISSING_FIELD", "INVALID_FIELD", "INVALID_REQUEST_BODY", "INVALID_API_KEY", "API_KEY_EXPIRED", "UNAUTHORIZED", "CONTACT_NOT_FOUND", "CAMPAIGN_NOT_FOUND", "NOT_FOUND", "MISSING_PHONE_NUMBER", "PLAN_REQUIRED", "SMS_LIMIT_EXCEEDED", "METHOD_NOT_ALLOWED", "INTERNAL_ERROR"]
              },
              "message": { "type": "string", "description": "Human-readable explanation. Wording may change; do not parse it." },
              "details": {
                "type": "object",
                "properties": {
                  "field": { "type": "string", "description": "Which request field caused the error." },
                  "value": { "description": "The offending value, when safe to echo." },
                  "suggestion": { "type": "string", "description": "How to fix it." }
                }
              }
            },
            "required": ["code", "message"]
          }
        },
        "required": ["success", "error"]
      }
    },
    "responses": {
      "BadRequest": {
        "description": "The request was malformed or failed validation.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
      },
      "Unauthorized": {
        "description": "The x-api-key header is missing, invalid or expired.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
      },
      "NotFound": {
        "description": "The referenced contact, campaign or endpoint does not exist in this tenant.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
      },
      "InternalError": {
        "description": "Unexpected server error. Safe to retry idempotent operations.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
      }
    }
  }
}
