{
  "openapi": "3.1.0",
  "info": {
    "title": "Nervly Gateway API",
    "description": "Nervly is notification infrastructure for Africa-first products: one API call fans a message out across SMS, email, push, WhatsApp and voice, with routing, failover and compliance handled for you.\n\nThis reference is generated from the gateway's Rust source on every commit — the types and prose you see here are the same ones the server compiles against, so it cannot describe an endpoint that does not exist.\n\n**Base URL:** `https://api.nervly.io`\n\n**Getting started:** see the [Quickstart](/docs/quickstart) for a working request in under five minutes.",
    "contact": {
      "name": "Nervly Engineering",
      "email": "engineering@nervly.io"
    },
    "license": {
      "name": "Proprietary"
    },
    "version": "0.1.0"
  },
  "servers": [
    {
      "url": "https://api.nervly.io",
      "description": "Production"
    },
    {
      "url": "http://localhost:8080",
      "description": "Local development"
    }
  ],
  "paths": {
    "/v1/events/bulk": {
      "post": {
        "tags": [
          "Events"
        ],
        "summary": "Trigger notifications in bulk.",
        "description": "Prefer this over looping on `POST /v1/events/trigger` when you have more\nthan a handful of notifications: one HTTP round trip, one authentication\ncheck, and the events are published concurrently.\n\nEach event is validated, routed and published exactly as a single trigger\nwould be, so anything accepted here behaves identically to a single call.\nEvents succeed or fail **individually**: the response reports each outcome\nby index, and a rejected event never blocks the rest of the batch. Check\n`failedCount` rather than the HTTP status.\n\n**Priority is ignored for bulk.** Every event in a batch is published to the\n`NOTIFY_BULK` stream (`notify.bulk.{channel}` subject), regardless of the\n`X-Priority-Override` header. This is observable behaviour: a bulk campaign\ncan never sit in front of another customer's password reset.\n\nRate limits still apply per `to.subscriberId`, so a batch aimed at one\nsubscriber can exhaust that subscriber's budget and see later entries\nrejected while the rest of your traffic is unaffected.\n\nAn empty `events` array, or one longer than 5,000 entries, is rejected with\n`400` before anything is published.",
        "operationId": "trigger-notifications-bulk",
        "parameters": [
          {
            "name": "X-Priority-Override",
            "in": "header",
            "description": "Ignored for bulk. Every event is published to the `NOTIFY_BULK` stream (`notify.bulk.{channel}`) regardless of priority.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BulkTriggerRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Batch processed — inspect `failedCount` and `events` for per-event outcomes",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BulkTriggerResponse"
                }
              }
            }
          },
          "400": {
            "description": "The events array was empty or exceeded 5,000 entries",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        },
        "security": [
          {
            "bearer_api_key": []
          }
        ]
      }
    },
    "/v1/events/trigger": {
      "post": {
        "tags": [
          "Events"
        ],
        "summary": "Trigger a notification.",
        "description": "Accepts one event, resolves its priority, encodes it as Protobuf and\npublishes it to NATS JetStream on the subject\n`notify.{class}.{channel}`, where `class` is `critical` for\n`CRITICAL`/`HIGH` (stream `NOTIFY_CRITICAL`) or `bulk` for `NORMAL`/`LOW`\n(stream `NOTIFY_BULK`). The tenant is carried in the Protobuf payload, so\nsubject cardinality does not grow with signups and a bulk campaign can never\nsit in front of another customer's OTP. The call returns as soon as the\nbroker acknowledges the write, so it is safe on a hot request path.\n\n**Idempotency.** Send an `Idempotency-Key` header and a replay within the\nretention window returns the original `200` response body instead of\nenqueuing a second notification. Without the header, every call enqueues.\n\n**Rate limiting.** Limits are applied per `to.subscriberId`, not per API\nkey, so one noisy end user cannot starve the rest of your traffic.",
        "operationId": "trigger-notification",
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "description": "Client-generated unique key. Replays return the original response instead of sending again.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "X-Priority-Override",
            "in": "header",
            "description": "One of CRITICAL, HIGH, NORMAL, LOW. Selects the JetStream lane. Defaults to NORMAL.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TriggerRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Idempotent replay — the original response, replayed verbatim",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TriggerResponse"
                }
              }
            }
          },
          "202": {
            "description": "Event accepted and queued for delivery",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TriggerResponse"
                }
              }
            }
          },
          "400": {
            "description": "The recipient has no contact details for any channel",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (workspace quota or per-subscriber abuse cap)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "500": {
            "description": "Encoding or broker failure",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        },
        "security": [
          {
            "bearer_api_key": []
          }
        ]
      }
    },
    "/v1/events/{eventId}": {
      "get": {
        "tags": [
          "Events"
        ],
        "summary": "Look up the status and event timeline for a single message by event ID.",
        "operationId": "get_event_status",
        "parameters": [
          {
            "name": "eventId",
            "in": "path",
            "description": "Event identifier (`evt_<hex>` or UUID)",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Message status and timeline",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MessageDto"
                }
              }
            }
          },
          "400": {
            "description": "Invalid event ID format",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "404": {
            "description": "Event not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "500": {
            "description": "Internal server error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "503": {
            "description": "Database unavailable",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        },
        "security": [
          {
            "bearer_api_key": []
          }
        ]
      }
    },
    "/v1/health": {
      "get": {
        "tags": [
          "Health"
        ],
        "summary": "Report gateway health.",
        "description": "Unauthenticated. Use this as your load-balancer health check and as a\nquick way to confirm which gateway version is live in an environment.\nA `200` means the process is accepting new work; inspect `nats_connected`\nto tell whether it can currently reach the broker. A `503` with\n`\"status\": \"DRAINING\"` means the process received a termination signal and\nis finishing in-flight requests before exit — a load balancer must take it\nout of rotation and stop sending new traffic.",
        "operationId": "get-health",
        "responses": {
          "200": {
            "description": "Gateway is serving traffic",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HealthStatus"
                }
              }
            }
          },
          "503": {
            "description": "Gateway is draining after a termination signal; stop routing new traffic to this instance",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HealthStatus"
                }
              }
            }
          }
        }
      }
    },
    "/v1/mcp": {
      "post": {
        "tags": [
          "MCP"
        ],
        "summary": "Invoke a Model Context Protocol tool.",
        "description": "Lets an AI agent inspect the gateway the same way an operator would. Two\ntools are exposed today: `gateway_status` reports uptime and broker\nconnectivity, and `idempotency_inspect` reports how many idempotency keys\nare being tracked.\n\nPoint an MCP-capable client at this endpoint with your API key as a bearer\ntoken to let it answer questions like \"is Nervly currently reaching the\nbroker?\" without giving it shell access.",
        "operationId": "invoke-mcp-tool",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/McpRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Tool result, or an error object for unsupported methods",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/McpResponse"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        },
        "security": [
          {
            "bearer_api_key": []
          }
        ]
      }
    },
    "/v1/messages": {
      "get": {
        "tags": [
          "Events"
        ],
        "summary": "List messages with optional filters and cursor-based pagination.",
        "operationId": "list_messages",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "description": "Filter by status (`TRIGGERED`, `DELIVERED`, `FAILED`, etc.).",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "channel",
            "in": "query",
            "description": "Filter by channel (`sms`, `email`, `whatsapp`, `push`).",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "subscriber_id",
            "in": "query",
            "description": "Filter by subscriber ID.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "subscriber",
            "in": "query",
            "description": "Alias for subscriber_id.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "from",
            "in": "query",
            "description": "Filter messages created on or after this RFC3339 timestamp.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ],
              "format": "date-time"
            }
          },
          {
            "name": "to",
            "in": "query",
            "description": "Filter messages created on or before this RFC3339 timestamp.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ],
              "format": "date-time"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Maximum number of messages to return (default 50, maximum 100).",
            "required": false,
            "schema": {
              "type": [
                "integer",
                "null"
              ],
              "minimum": 0
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "description": "Opaque pagination cursor returned by a previous page.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of messages",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ListMessagesResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid query parameters or cursor",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "500": {
            "description": "Internal server error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "503": {
            "description": "Database unavailable",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        },
        "security": [
          {
            "bearer_api_key": []
          }
        ]
      }
    },
    "/v1/subscribers/{subscriberId}": {
      "delete": {
        "tags": [
          "Subscribers"
        ],
        "summary": "Erase a subscriber and delete their personal data (NDPR / right-to-erasure).",
        "description": "Anonymises the subscriber row, clears email, phone, device tokens, sets\n`deleted_at`, deletes `subscriber_preferences`, scrubs message identifiers\nolder than the audit window, and logs the erasure event in the audit trail.\n\nSubsequent message deliveries queued or attempted for this subscriber are\nautomatically suppressed by the worker router.",
        "operationId": "erase-subscriber",
        "parameters": [
          {
            "name": "subscriberId",
            "in": "path",
            "description": "Your identifier for the end user, as sent in `to.subscriberId`",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Subscriber erasure accepted",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SubscriberErasureResponse"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "500": {
            "description": "The preference store is unavailable — erasure failed",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        },
        "security": [
          {
            "bearer_api_key": []
          }
        ]
      }
    },
    "/v1/users/{subscriberId}/preferences": {
      "put": {
        "tags": [
          "Subscribers"
        ],
        "summary": "Update a subscriber's notification preferences.",
        "description": "This is how you honour opt-outs. The workers read these preferences from\nPostgres immediately before dispatching, so a suppressed channel is never\nattempted and never billed.\n\nThe write is a merge, not a replace, one level deep: a channel you omit\nkeeps its current value, and a category you send replaces that category's\nflags wholesale. To re-enable a channel, send it explicitly as `true`.\n\nMerging happens inside Postgres rather than as a read-modify-write, so two\nconcurrent updates to different channels cannot lose each other.",
        "operationId": "update-subscriber-preferences",
        "parameters": [
          {
            "name": "subscriberId",
            "in": "path",
            "description": "Your identifier for the end user, as sent in `to.subscriberId`",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UserPreferencesRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Preferences stored",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UserPreferencesResponse"
                }
              }
            }
          },
          "400": {
            "description": "Neither channels nor categories was supplied",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "500": {
            "description": "The preference store is unavailable — nothing was written",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        },
        "security": [
          {
            "bearer_api_key": []
          }
        ]
      }
    },
    "/v1/webhooks/{provider}": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Receive a provider delivery receipt.",
        "description": "Point each provider's webhook setting at\n`https://api.nervly.io/v1/webhooks/{provider}` and Nervly normalises their\nreceipts into delivery telemetry.\n\n**Authentication is by signature, not API key.** This route is outside the\nbearer-token surface because providers cannot present your key.\nSupported providers and their signature schemes:\n- `resend`: Svix HMAC-SHA256 signatures via headers `svix-id`, `svix-timestamp`, `svix-signature`.\n- `zeptomail`: Producer signature via header `producer-signature: ts=...;s=...;s-algorithm=HmacSHA256`.\n- `termii`: `X-Termii-Signature` (HMAC-SHA256 of the raw body).\n- `twilio`: `X-Twilio-Signature`.",
        "operationId": "receive-delivery-receipt",
        "parameters": [
          {
            "name": "provider",
            "in": "path",
            "description": "Provider slug, e.g. `termii`, `twilio`, `resend`, `zeptomail`",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "X-Termii-Signature",
            "in": "header",
            "description": "HMAC-SHA256 of the raw request body, keyed with your Termii webhook secret",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "X-Twilio-Signature",
            "in": "header",
            "description": "Twilio request signature. Required in production.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "svix-id",
            "in": "header",
            "description": "Resend / Svix unique message ID",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "svix-timestamp",
            "in": "header",
            "description": "Resend / Svix epoch timestamp in seconds",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "svix-signature",
            "in": "header",
            "description": "Resend / Svix HMAC-SHA256 signature",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "producer-signature",
            "in": "header",
            "description": "ZeptoMail producer signature token",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/GenericWebhookPayload"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Receipt accepted and published to telemetry",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookResponse"
                }
              }
            }
          },
          "401": {
            "description": "Signature missing or did not verify",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "500": {
            "description": "Encoding or broker failure",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "BulkEventResult": {
        "type": "object",
        "description": "The outcome for one event within a batch.",
        "required": [
          "index",
          "status"
        ],
        "properties": {
          "channel": {
            "type": [
              "string",
              "null"
            ],
            "description": "The channel the event was queued on, when it was queued.",
            "example": "email"
          },
          "error": {
            "type": [
              "string",
              "null"
            ],
            "description": "Why this event was rejected, when it was.",
            "example": "Subscriber usr_1 has no reachable channel"
          },
          "eventId": {
            "type": [
              "string",
              "null"
            ],
            "description": "Nervly's identifier for the event, present when it was queued.",
            "example": "evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8"
          },
          "index": {
            "type": "integer",
            "description": "Zero-based position of this event in the submitted `events` array, so a\nfailure can be matched back to what you sent.",
            "minimum": 0
          },
          "status": {
            "type": "string",
            "description": "`QUEUED` or `FAILED`.",
            "example": "QUEUED"
          }
        }
      },
      "BulkTriggerRequest": {
        "type": "object",
        "description": "A batch of notifications to enqueue in a single call.",
        "required": [
          "events"
        ],
        "properties": {
          "events": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TriggerRequest"
            },
            "description": "Events to enqueue. Each entry has the same shape as a single\n`POST /v1/events/trigger` body. Must contain between 1 and\n[`MAX_BATCH_SIZE`] entries."
          }
        }
      },
      "BulkTriggerResponse": {
        "type": "object",
        "description": "The result of a batch submission.",
        "required": [
          "jobId",
          "status",
          "count",
          "failedCount",
          "events"
        ],
        "properties": {
          "count": {
            "type": "integer",
            "description": "Number of events successfully queued.",
            "example": 2500,
            "minimum": 0
          },
          "events": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/BulkEventResult"
            },
            "description": "Per-event outcomes, in the order submitted."
          },
          "failedCount": {
            "type": "integer",
            "description": "Number of events rejected. Zero when `status` is `QUEUED`.",
            "example": 0,
            "minimum": 0
          },
          "jobId": {
            "type": "string",
            "description": "Identifier for the batch, for correlating logs. Individual events each\ncarry their own `eventId`.",
            "example": "job_batch_4d2f8a1c9b3e7f60"
          },
          "status": {
            "type": "string",
            "description": "`QUEUED` when every event was accepted, `PARTIAL` when some were not.\nA batch where nothing succeeded is still reported here rather than as an\nerror status, because the per-event reasons are what you need.",
            "example": "QUEUED"
          }
        }
      },
      "ChannelPreferences": {
        "type": "object",
        "description": "Per-channel opt-in flags.\n\nOmitting a channel leaves its current setting untouched; setting it to\n`false` suppresses every notification on that channel for this subscriber,\nincluding ones your workflow marks as CRITICAL. Use a category override if\nyou need an exemption for one kind of message.",
        "properties": {
          "email": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Allow email delivery."
          },
          "push": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Allow mobile push delivery."
          },
          "sms": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Allow SMS delivery."
          },
          "voice": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Allow voice calls."
          },
          "whatsapp": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Allow WhatsApp delivery."
          }
        }
      },
      "EmailOverrideDto": {
        "type": "object",
        "description": "Per-request overrides for the email channel.",
        "properties": {
          "customHeaders": {
            "type": [
              "object",
              "null"
            ],
            "description": "Additional SMTP headers to attach, such as `Reply-To`.",
            "additionalProperties": {
              "type": "string"
            },
            "propertyNames": {
              "type": "string"
            }
          },
          "provider": {
            "type": [
              "string",
              "null"
            ],
            "description": "Preferred or forced email provider for this event (e.g. \"resend\", \"zeptomail\").",
            "example": "resend"
          },
          "sender": {
            "type": [
              "string",
              "null"
            ],
            "description": "Replace the configured From address for this event only.",
            "example": "billing@yourcompany.com"
          }
        }
      },
      "ErrorResponse": {
        "type": "object",
        "description": "The JSON body returned for every non-2xx response.",
        "required": [
          "error",
          "message",
          "status_code"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "Stable machine-readable code — for example `RATE_LIMIT_EXCEEDED`.\nBranch on this, not on `message`.",
            "example": "RATE_LIMIT_EXCEEDED"
          },
          "message": {
            "type": "string",
            "description": "Human-readable explanation. Safe to log, not safe to parse.",
            "example": "Client ingestion rate limit exceeded"
          },
          "status_code": {
            "type": "integer",
            "format": "int32",
            "description": "The HTTP status, repeated in the body for clients that only surface\nthe payload.",
            "example": 429,
            "minimum": 0
          }
        }
      },
      "EventItemDto": {
        "type": "object",
        "description": "A timeline step in the lifecycle of a message.",
        "required": [
          "seq",
          "status",
          "occurred_at"
        ],
        "properties": {
          "detail": {
            "description": "Additional provider or failure details."
          },
          "occurred_at": {
            "type": "string",
            "format": "date-time",
            "description": "Timestamp when this event step occurred."
          },
          "provider": {
            "type": [
              "string",
              "null"
            ],
            "description": "Provider executing this step.",
            "example": "termii"
          },
          "seq": {
            "type": "integer",
            "format": "int64",
            "description": "Monotonic sequence number.",
            "example": 1
          },
          "status": {
            "type": "string",
            "description": "Step status (`queued`, `sent`, `delivered`, `failed`).",
            "example": "delivered"
          }
        }
      },
      "GenericWebhookPayload": {
        "type": "object",
        "description": "Normalised delivery receipt, covering the fields Nervly extracts from every\nprovider's own webhook body.",
        "properties": {
          "channel": {
            "type": [
              "string",
              "null"
            ],
            "description": "Channel the message went out on.",
            "example": "sms"
          },
          "cost": {
            "type": [
              "number",
              "null"
            ],
            "format": "float",
            "description": "Provider-reported cost in USD.",
            "example": 0.002
          },
          "latency_ms": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int32",
            "description": "Provider-reported end-to-end latency in milliseconds.",
            "example": 120,
            "minimum": 0
          },
          "message_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "The provider's own identifier for the message.",
            "example": "SM3f8a1c9b"
          },
          "recipient": {
            "type": [
              "string",
              "null"
            ],
            "description": "Address or token the message was sent to.",
            "example": "+2348012345678"
          },
          "status": {
            "type": [
              "string",
              "null"
            ],
            "description": "Delivery state: one of `TRIGGERED`, `QUEUED`, `SENT`, `DELIVERED`,\n`SEEN`, `CLICKED`, `BOUNCED_HARD`, `BOUNCED_SOFT`, `COMPLAINED`.",
            "example": "DELIVERED"
          }
        }
      },
      "HealthStatus": {
        "type": "object",
        "description": "Point-in-time snapshot of gateway health and its broker connection.",
        "required": [
          "status",
          "service",
          "version",
          "environment",
          "uptime_seconds",
          "nats_connected"
        ],
        "properties": {
          "environment": {
            "type": "string",
            "description": "Deployment environment: `development`, `staging` or `production`.",
            "example": "production"
          },
          "nats_connected": {
            "type": "boolean",
            "description": "Whether the gateway currently holds a NATS JetStream connection. When\n`false`, accepted events are buffered rather than published."
          },
          "service": {
            "type": "string",
            "description": "Service identifier, useful when several Nervly components sit behind\none load balancer.",
            "example": "nervly-gateway"
          },
          "status": {
            "type": "string",
            "description": "`OK` while the process is accepting work, `DRAINING` once a termination\nsignal has been received and the in-flight drain has begun.",
            "example": "OK"
          },
          "uptime_seconds": {
            "type": "integer",
            "format": "int64",
            "description": "Seconds since this process finished booting.",
            "example": 86400,
            "minimum": 0
          },
          "version": {
            "type": "string",
            "description": "Semantic version of the running binary, taken from `Cargo.toml`.",
            "example": "0.1.0"
          }
        }
      },
      "ListMessagesResponse": {
        "type": "object",
        "description": "Paginated list of messages.",
        "required": [
          "messages"
        ],
        "properties": {
          "messages": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MessageDto"
            },
            "description": "Messages matching filters."
          },
          "next_cursor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Cursor for fetching the next page, or omitted if at the end."
          }
        }
      },
      "McpRequest": {
        "type": "object",
        "description": "A JSON-RPC style MCP call.",
        "required": [
          "method"
        ],
        "properties": {
          "method": {
            "type": "string",
            "description": "MCP method to invoke. `tools/list` enumerates the available tools;\n`tools/call` executes one. Unknown methods return an error object\nrather than a non-200 status.",
            "example": "tools/list"
          },
          "params": {
            "type": [
              "object",
              "null"
            ],
            "description": "Method arguments. Shape depends on `method`."
          }
        }
      },
      "McpResponse": {
        "type": "object",
        "description": "A JSON-RPC style MCP response.",
        "required": [
          "jsonrpc",
          "result",
          "id"
        ],
        "properties": {
          "id": {
            "type": "integer",
            "format": "int64",
            "description": "Correlation id.",
            "minimum": 0
          },
          "jsonrpc": {
            "type": "string",
            "description": "Protocol version. Always `2.0`.",
            "example": "2.0"
          },
          "result": {
            "type": "object",
            "description": "Method result. Shape depends on the method that was invoked."
          }
        }
      },
      "MessageDto": {
        "type": "object",
        "description": "A recorded message in the platform.",
        "required": [
          "event_id",
          "event_name",
          "subscriber_id",
          "priority",
          "status",
          "attempts",
          "cost_micro_usd",
          "test_mode",
          "variables_keys",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "attempts": {
            "type": "integer",
            "format": "int32",
            "description": "Number of delivery attempts made.",
            "example": 1
          },
          "category": {
            "type": [
              "string",
              "null"
            ],
            "description": "Event category.",
            "example": "transactional"
          },
          "channel": {
            "type": [
              "string",
              "null"
            ],
            "description": "Channel dispatched or selected.",
            "example": "sms"
          },
          "cost_micro_usd": {
            "type": "integer",
            "format": "int64",
            "description": "Cost incurred in micro-USD ($0.000001).",
            "example": 5000
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "Ingestion timestamp."
          },
          "error_code": {
            "type": [
              "string",
              "null"
            ],
            "description": "Error code if delivery failed."
          },
          "error_detail": {
            "type": [
              "string",
              "null"
            ],
            "description": "Human-readable error detail if delivery failed."
          },
          "event_id": {
            "type": "string",
            "description": "Public event ID (`evt_<hex>`).",
            "example": "evt_018e123456787abc8def0123456789ab"
          },
          "event_name": {
            "type": "string",
            "description": "Event name originally triggered.",
            "example": "order.confirmed"
          },
          "events": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "$ref": "#/components/schemas/EventItemDto"
            },
            "description": "Chronological delivery timeline events."
          },
          "priority": {
            "type": "integer",
            "format": "int32",
            "description": "Priority level (0 = Critical, 1 = High, 2 = Normal, 3 = Low).",
            "example": 1
          },
          "provider": {
            "type": [
              "string",
              "null"
            ],
            "description": "Downstream provider that handled or attempted delivery.",
            "example": "termii"
          },
          "provider_message_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Provider's external message ID.",
            "example": "msg_ext_778899"
          },
          "status": {
            "type": "string",
            "description": "Current delivery status (`TRIGGERED`, `DELIVERED`, `FAILED`, etc.).",
            "example": "DELIVERED"
          },
          "subscriber_id": {
            "type": "string",
            "description": "Target subscriber identifier.",
            "example": "usr_prod_9921"
          },
          "test_mode": {
            "type": "boolean",
            "description": "True if triggered using a test API key.",
            "example": false
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "Last update timestamp."
          },
          "variables_keys": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Keys of variables supplied in the payload. Raw values are not stored.",
            "example": [
              "order_id",
              "amount"
            ]
          }
        }
      },
      "ProviderOverridesDto": {
        "type": "object",
        "description": "Channel-specific overrides applied to this event only.\n\nEverything here is optional; anything you omit falls back to the channel\nconfiguration held in your Nervly workspace.",
        "properties": {
          "email": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/EmailOverrideDto",
                "description": "Email channel overrides."
              }
            ]
          },
          "extraParams": {
            "type": [
              "object",
              "null"
            ],
            "description": "Free-form provider parameters passed straight through to the\ndownstream provider adapter.",
            "additionalProperties": {
              "type": "string"
            },
            "propertyNames": {
              "type": "string"
            }
          },
          "sms": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/SmsOverrideDto",
                "description": "SMS channel overrides."
              }
            ]
          },
          "voice": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/VoiceOverrideDto",
                "description": "Voice channel overrides. Present means \"call this recipient\"; the\nindividual fields are optional fallbacks."
              }
            ]
          },
          "whatsapp": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/WhatsAppOverrideDto",
                "description": "WhatsApp channel overrides."
              }
            ]
          }
        }
      },
      "RecipientDto": {
        "type": "object",
        "description": "Who the notification is for, and how each channel can reach them.\n\nOnly `subscriberId` is required. The contact fields you supply determine\nwhich channels are eligible: omit `email` and no email will be attempted.",
        "required": [
          "subscriberId"
        ],
        "properties": {
          "deviceTokens": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            },
            "description": "FCM or APNs device tokens for the `push` channel."
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "description": "Email address for the `email` channel.",
            "example": "ada@example.com"
          },
          "phone": {
            "type": [
              "string",
              "null"
            ],
            "description": "E.164 phone number for the `sms`, `whatsapp` and `voice` channels.",
            "example": "+2348012345678"
          },
          "subscriberId": {
            "type": "string",
            "description": "Your stable identifier for the end user. Also the rate-limit bucket\nand the last segment of the NATS subject the event is published to.",
            "example": "user_8f21c"
          }
        }
      },
      "SmsOverrideDto": {
        "type": "object",
        "description": "Per-request overrides for the SMS channel.",
        "properties": {
          "sender": {
            "type": [
              "string",
              "null"
            ],
            "description": "Replace the resolved Sender ID for this event only. Falls back to the\ntenant's provider credential, then \"Nervly\", when omitted. Nigerian NCC\nrules reject or rewrite unregistered alphanumeric Sender IDs.",
            "example": "YourBrand"
          }
        }
      },
      "SubscriberErasureResponse": {
        "type": "object",
        "description": "Response confirming that an erasure request was accepted.",
        "required": [
          "status",
          "subscriberId",
          "message"
        ],
        "properties": {
          "message": {
            "type": "string",
            "description": "Human-readable confirmation.",
            "example": "Subscriber erasure initiated and completed"
          },
          "status": {
            "type": "string",
            "description": "Always `accepted`.",
            "example": "accepted"
          },
          "subscriberId": {
            "type": "string",
            "description": "The subscriber that was erased.",
            "example": "user_8f21c"
          }
        }
      },
      "TelemetryReceiptEvent": {
        "type": "object",
        "description": "Normalized telemetry receipt published to NATS JetStream subject `telemetry.receipts`.",
        "required": [
          "provider",
          "message_id",
          "recipient",
          "status",
          "timestamp",
          "raw_event_id"
        ],
        "properties": {
          "bounce_reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Diagnostic failure message or bounce reason."
          },
          "message_id": {
            "type": "string",
            "description": "Upstream message identifier.",
            "example": "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794"
          },
          "provider": {
            "type": "string",
            "description": "External delivery provider reporting the receipt.",
            "example": "resend"
          },
          "raw_event_id": {
            "type": "string",
            "description": "Internal Nervly receipt event identifier (`dlr_...`).",
            "example": "dlr_1a2b3c4d5e6f70819"
          },
          "recipient": {
            "type": "string",
            "description": "Recipient address or token.",
            "example": "user@example.com"
          },
          "status": {
            "type": "string",
            "description": "Canonical receipt status (`DELIVERED`, `BOUNCED_HARD`, `BOUNCED_SOFT`, `COMPLAINED`).",
            "example": "DELIVERED"
          },
          "timestamp": {
            "type": "string",
            "description": "ISO 8601 / RFC 3339 timestamp string.",
            "example": "2026-09-17T02:22:00Z"
          },
          "workspace_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Multi-tenant workspace identifier if known."
          }
        }
      },
      "TriggerRequest": {
        "type": "object",
        "description": "A request to send one notification.",
        "required": [
          "name",
          "to"
        ],
        "properties": {
          "category": {
            "type": [
              "string",
              "null"
            ],
            "description": "Preference category this notification belongs to — for example\n`marketing` or `receipts`.\n\nMatched against the keys of a subscriber's `categories` preferences, so\na subscriber who has muted `marketing` still receives everything else.\nDefaults to `name` when omitted, which means an unlabelled workflow can\nstill be muted by its own name.",
            "example": "receipts"
          },
          "name": {
            "type": "string",
            "description": "Workflow name to run, as configured in your Nervly workspace.",
            "example": "payment-received"
          },
          "overrides": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/ProviderOverridesDto",
                "description": "Channel-specific overrides for this event."
              }
            ]
          },
          "payload": {
            "type": [
              "object",
              "null"
            ],
            "description": "Template variables. Any JSON object; keys are referenced from your\ntemplates as `{{key}}`."
          },
          "to": {
            "$ref": "#/components/schemas/RecipientDto",
            "description": "The recipient and their contact details."
          }
        }
      },
      "TriggerResponse": {
        "type": "object",
        "description": "Acknowledgement that an event was accepted and queued for delivery.\n\nA `202` does not mean the notification was delivered — it means Nervly has\ndurably accepted responsibility for it. Delivery outcomes arrive as\ndelivery receipts on your webhook endpoint.",
        "required": [
          "eventId",
          "status",
          "priority",
          "channel",
          "timestamp"
        ],
        "properties": {
          "channel": {
            "type": "string",
            "description": "The channel this event was queued on, chosen from the contact details\nsupplied and the resolved priority. Urgent events take the most\nreachable channel, everything else the cheapest.\n\nOne of `sms`, `email`, `push`, `whatsapp` or `voice`. The workers may\nstill fail over to another channel if every provider on this one\nrejects the message; the delivery receipt reports what actually\nhappened.",
            "example": "email"
          },
          "eventId": {
            "type": "string",
            "description": "Nervly's identifier for this event. Quote it in support requests and\nuse it to correlate delivery receipts.",
            "example": "evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8"
          },
          "idempotencyKey": {
            "type": [
              "string",
              "null"
            ],
            "description": "Echo of the `Idempotency-Key` header, when one was supplied."
          },
          "priority": {
            "type": "string",
            "description": "Resolved priority, after applying the `X-Priority-Override` header.",
            "example": "NORMAL"
          },
          "status": {
            "type": "string",
            "description": "Always `QUEUED` on a fresh acceptance.",
            "example": "QUEUED"
          },
          "timestamp": {
            "type": "string",
            "description": "RFC 3339 timestamp of acceptance.",
            "example": "2026-08-28T09:15:04.221Z"
          }
        }
      },
      "UserPreferencesRequest": {
        "type": "object",
        "description": "A preference update for one subscriber.",
        "properties": {
          "categories": {
            "type": [
              "object",
              "null"
            ],
            "description": "Category-level overrides, keyed by your own category name and then by\nchannel — for example `{\"marketing\": {\"email\": false, \"sms\": false}}`.\nCategory rules take precedence over the channel-level flags above."
          },
          "channels": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/ChannelPreferences",
                "description": "Channel-level opt-in flags."
              }
            ]
          }
        }
      },
      "UserPreferencesResponse": {
        "type": "object",
        "description": "Confirmation that a subscriber's preferences were stored.",
        "required": [
          "status",
          "subscriberId",
          "updated_at"
        ],
        "properties": {
          "status": {
            "type": "string",
            "description": "Always `UPDATED` on success.",
            "example": "UPDATED"
          },
          "subscriberId": {
            "type": "string",
            "description": "The subscriber whose preferences changed.",
            "example": "user_8f21c"
          },
          "updated_at": {
            "type": "string",
            "description": "RFC 3339 timestamp of the write.",
            "example": "2026-08-28T09:15:04.221Z"
          }
        }
      },
      "VoiceOverrideDto": {
        "type": "object",
        "description": "Per-request overrides for the Voice channel.\n\nSupplying this at all opts the event into voice; voice calls cost roughly\nthree times an SMS and ring a phone, so the channel is never inferred from\nthe recipient merely having a number.",
        "properties": {
          "language": {
            "type": [
              "string",
              "null"
            ],
            "description": "BCP-47 language tag for the voice profile.",
            "example": "en-US"
          },
          "script": {
            "type": [
              "string",
              "null"
            ],
            "description": "Spoken script for the call. Falls back to the workspace voice template\nwhen omitted.",
            "example": "Your verification code is 48291"
          },
          "voice_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "TTS voice profile to speak the script in. Serialised snake_case\n(`voice_id`) to match the canonical `VoiceOverride` contract and the\nworker key; `voiceId` is accepted as an input alias.",
            "example": "Ada"
          }
        }
      },
      "WebhookResponse": {
        "type": "object",
        "description": "Acknowledgement that a delivery receipt was accepted.",
        "required": [
          "status",
          "provider",
          "event_id"
        ],
        "properties": {
          "event_id": {
            "type": "string",
            "description": "Nervly's identifier for this receipt.",
            "example": "dlr_1a2b3c4d5e6f70819"
          },
          "provider": {
            "type": "string",
            "description": "The provider slug taken from the URL path.",
            "example": "termii"
          },
          "status": {
            "type": "string",
            "description": "Always `ACCEPTED` once the receipt is on the telemetry stream.",
            "example": "ACCEPTED"
          }
        }
      },
      "WhatsAppOverrideDto": {
        "type": "object",
        "description": "Per-request overrides for the WhatsApp channel.",
        "properties": {
          "language": {
            "type": [
              "string",
              "null"
            ],
            "description": "BCP-47 language tag for template resolution.",
            "example": "en_US"
          },
          "template_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Name of the pre-approved Meta Cloud API template to send.",
            "example": "order_shipped_v3"
          }
        }
      }
    },
    "securitySchemes": {
      "bearer_api_key": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "API key",
        "description": "Send your workspace API key as `Authorization: Bearer <key>`. Keys are issued from Dashboard → Settings → Developer Applications and carry the scopes granted to the application that created them."
      }
    }
  },
  "tags": [
    {
      "name": "Events",
      "description": "Send notifications, singly or in bulk."
    },
    {
      "name": "Subscribers",
      "description": "Manage per-user channel and category preferences."
    },
    {
      "name": "Webhooks",
      "description": "Delivery receipts posted back to Nervly by your providers."
    },
    {
      "name": "MCP",
      "description": "Model Context Protocol tools for AI agents."
    },
    {
      "name": "Health",
      "description": "Liveness and version reporting."
    }
  ]
}
