# API v1 Contract Freeze

Status: RC1-1 architecture approved and amended by RC1-5A for tenant delivery webhooks. Implemented resources remain governed by their scheduled milestones.

This document records the audit, rationale, and intended RC1 public API contract; it is not evidence that an endpoint exists. `docs/api/openapi.yaml` is the normative implementation source of truth. If prose and OpenAPI conflict, OpenAPI governs fields and HTTP behavior while the conflict is corrected. Where the repository does not contain an approved value, this document records an explicit unresolved decision instead of inventing one.

## Repository audit

| Resource | Classification | Current state and required compatibility work |
| --- | --- | --- |
| `POST /api/v1/messages` | `EXISTING_REQUIRES_HARDENING` | Exists, but uses `to`, required `from`, `message`, and optional `correlation_id`; returns the legacy resource envelope. Idempotency conflicts rather than replaying an identical request. An additive compatibility adapter and replay store are required. |
| `POST /api/v1/messages/bulk` | `MISSING` | No route, controller, service, durable batch identity, or itemized bulk contract exists. |
| `GET /api/v1/messages/{message_id}` | `EXISTING_REQUIRES_HARDENING` | The path exists with a differently named route parameter and returns legacy internal status dimensions. It must expose the frozen normalized representation without weakening tenant/application isolation. |
| `GET /api/v1/messages/{message_id}/status` | `MISSING` | No dedicated normalized-status resource exists. |
| `PUT /api/v1/webhook` | `MISSING` | RC1-5A approves create-or-replace of one tenant delivery-webhook configuration; implementation remains RC1-6 work. |
| `GET /api/v1/webhook` | `MISSING` | RC1-5A approves one safe tenant webhook read model; implementation remains RC1-6 work. |

The existing `GET /api/v1/messages` collection endpoint is outside these six resources. It remains a legacy surface until a separately approved compatibility decision retains, versions, or deprecates it.

## Common protocol

- Requests and responses use UTF-8 JSON over HTTPS in production.
- Authentication uses `Authorization: Bearer <credential>`. The implemented credential form is `mhi_<environment>_<12-character-prefix>.<48-character-secret>` and is resolved to one active tenant and application.
- Every HTTP request has its own request correlation ID. A client may supply `X-Correlation-ID` using 1–100 characters matching `[A-Za-z0-9][A-Za-z0-9._:-]{0,99}`; otherwise the gateway generates a UUIDv4. Every response carries `X-Request-ID` and an `X-Correlation-ID` equal to the current HTTP request correlation ID.
- A message submission response body uses `correlation_id` for the original message-submission correlation ID. On first submission, header and body normally match. On idempotent replay, the header contains the current replay request correlation ID while the body retains the original submission correlation ID.
- HTTP, access, and middleware logs use the current request correlation ID. Message/business submission logs use the original message correlation ID. The original message correlation ID is never regenerated during replay.
- Resource access remains tenant- and application-scoped. Cross-scope absence is returned as `NOT_FOUND` so resource existence is not disclosed.
- API credential issuance, expiry, revocation, and hashing exist. A zero-downtime rotation procedure remains an RC1 operational decision and must be approved before release.

## Single message submission

`POST /api/v1/messages` accepts exactly:

| Field | Required | Contract |
| --- | --- | --- |
| `recipient` | yes | E.164, `+` followed by 8–15 digits, first digit non-zero. |
| `message` | yes | UTF-8 JSON string, 1–4096 characters. RC1 deployment configuration must not exceed or contradict this frozen limit. |
| `sender_id` | no | Control-character-free string, maximum 191 characters. Omission requires an approved configured default; the current implementation requires `from`, so this needs hardening. |
| `client_reference` | no | 1–100 characters matching `[A-Za-z0-9][A-Za-z0-9._:-]{0,99}`. It is client metadata, not an idempotency authority. |

Unknown fields are rejected. The endpoint returns `202` with `message_id`, normalized `status`, optional `client_reference`, `created_at` as UTC RFC 3339 with microseconds, and `correlation_id`. Message IDs remain the existing public ULID format; this freeze does not introduce a second identifier.

`Idempotency-Key` is mandatory. It is visible ASCII after trimming, 1–191 characters. Within one tenant, a first valid request durably binds the key to a canonical request fingerprint and complete original message result. The same key and fingerprint returns that original message result, resource identity, body `correlation_id`, and status code; the response header still represents the current replay request. A different fingerprint returns `409 IDEMPOTENCY_CONFLICT`. Concurrent requests serialize around the durable record and cannot create two messages. Failed validation is not bound. The retention duration is **unresolved** and blocks RC1 implementation approval; no implementation may silently choose one. The current application-scoped uniqueness and conflict-only behavior require migration and compatibility design.

## Bulk message submission

`POST /api/v1/messages/bulk` accepts an optional batch-level `client_reference` and a `messages` array containing the frozen single-message fields: `recipient`, `message`, optional `sender_id`, and optional per-message `client_reference`. Array order is contract-significant. The array must contain 1–100 items. Missing `messages`, an empty array, an invalid top-level structure, or more than 100 items fails before item processing with HTTP `422 INVALID_REQUEST`.

Processing is best-effort and partial-success. Once the top-level request is valid, every message is independently evaluated in input order. An invalid recipient, sender, message body, excessive message length, or other approved item-level rejection does not reject valid siblings. Accepted items may be persisted while rejected items produce compact safe errors. The batch is not one all-or-nothing business transaction. Authentication failure, invalid or missing `Idempotency-Key`, idempotency conflict, rate limiting, and internal failure before a stable result exists remain request-level failures using the standard API error envelope.

A valid evaluated request returns HTTP `202`, including when every item is rejected. Its body contains a UUIDv4 `batch_id`, optional batch `client_reference`, `accepted`, `rejected`, and ordered `results`. Every result contains its original zero-based `index` and exactly one accepted or rejected representation. Accepted results contain `message_id`, public status `queued`, and optional per-message `client_reference`. Rejected results contain `error` with only `code`, `message`, and `details`. Exactly one result exists per input item, and `accepted + rejected` equals the input count.

`batch_id` is generated once for the original request, retained on replay, and used only for request/result tracking. It is not a public persistent batch resource: no batch table, lifecycle, GET endpoint, cancellation, retry endpoint, or history endpoint is required or approved. Durable replay metadata may be persisted behind the idempotency boundary.

The entire ordered bulk request has one mandatory tenant-scoped `Idempotency-Key`. Canonical equivalence preserves array order. Equivalent replay returns the original `batch_id`, optional batch reference, counters, item identities, item errors, ordering, and original result-body data. `X-Correlation-ID` still identifies the current replay HTTP request and is not replaced by `batch_id`; no batch `correlation_id` body field exists. A different payload returns HTTP `409 IDEMPOTENCY_CONFLICT`. Different tenants may reuse a key. Concurrent equivalent requests converge on one stable result, while concurrent conflicts fail closed without mixed results.

RC1 bulk submission adds no scheduled messages, templates, campaigns, public batch resource, DLR processing, message-status implementation, tenant webhook, MO handling, wallet/billing change, or SMPP runtime behavior.

## Message resources

`GET /api/v1/messages/{message_id}` returns:

- `message_id`, `recipient`, optional `sender_id`, optional `client_reference`;
- normalized `status`;
- `created_at`, optional `submitted_at`, and optional terminal `finalized_at` in UTC RFC 3339 with microseconds;
- request `correlation_id`.

Sensitive message content is returned only to the authenticated owning tenant/application and must never be logged. Provider credentials, raw SMPP PDUs, internal database IDs, billing dimensions, and provider message identifiers are not public.

`GET /api/v1/messages/{message_id}/status` returns `message_id`, normalized `status`, `occurred_at`, optional `client_reference`, and `correlation_id`. It deliberately omits message content.

## Normalized lifecycle

The closed public status vocabulary is:

`queued`, `submitted`, `delivered`, `failed`, `expired`, `rejected`, `unknown`.

Allowed transitions are:

- `queued` to `submitted`, any terminal state, or `unknown`;
- `submitted` to any terminal state or `unknown`;
- `unknown` to any terminal state;
- a terminal state (`delivered`, `failed`, `expired`, `rejected`) only to itself for identical evidence.

Terminal states are immutable. A contradictory late terminal event is retained internally for reconciliation and does not rewrite public history. `unknown` is non-terminal and means authoritative delivery outcome is unavailable or contradictory. The current internal message, delivery, dispatch, and attempt enums remain internal authorities and need an approved projection:

| Internal evidence | Public status |
| --- | --- |
| Received, Validated, Accepted, Queued, or Processing with no terminal evidence | `queued` |
| Durable provider submission/acceptance evidence or Submitted | `submitted` |
| Delivered | `delivered` |
| Failed, Cancelled, Undeliverable, or Deleted receipt | `failed` |
| Expired | `expired` |
| Rejected | `rejected` |
| Unknown delivery evidence, unconfirmed completion, or irreconcilable ambiguity | `unknown` |

Authoritative terminal delivery evidence takes precedence over non-terminal message state, but never over an already recorded conflicting terminal state.

## Delivery-receipt normalization

Supported SMPP textual states map as follows: `ENROUTE` and `ACCEPTD` to `submitted`; `DELIVRD` to `delivered`; `EXPIRED` to `expired`; `DELETED` and `UNDELIV` to `failed`; `REJECTD` to `rejected`; `UNKNOWN` to `unknown`.

Correlation uses the provider/connection authority together with provider message ID; a provider message ID is not globally unique. A receipt that cannot be uniquely correlated is preserved and quarantined for reconciliation without changing a public message. The gateway's durable receipt time is the public transition-time authority. Provider timestamps remain evidence only until a provider-specific validation policy is approved. Duplicate identical receipts are idempotent. Conflicting or late receipts cannot mutate a terminal state. Raw receipt text, TLVs, provider identifiers, and transport provenance remain internal.

## Delivery webhook configuration and events

RC1 supports exactly one delivery webhook configuration per tenant. `PUT /api/v1/webhook` creates the configuration when absent and completely replaces it when present; `GET /api/v1/webhook` returns its safe representation. There is no `POST`, `DELETE`, multiple endpoint, endpoint-priority, or event-filtering behavior. Configuration contains only `enabled`, `url`, and `secret`. Production URLs must use HTTPS. The secret must contain at least 32 cryptographically random bytes, is write-only, and is never returned after creation.

Replacing a configuration activates the newest secret immediately. The previous secret remains valid for receiver-side signature verification for exactly 24 hours, after which only the newest secret is valid. RC1 does not add a separate rotation endpoint.

RC1 emits one immutable webhook event only when a message first reaches one of the terminal public statuses `delivered`, `failed`, `expired`, or `rejected`. It emits no event for `queued`, `submitted`, or `unknown`. Duplicate DLR evidence and repeated observation of the same terminal outcome cannot create another webhook event or delivery. The JSON body contains only `message_id`, `status`, optional `client_reference`, optional `delivered_at`, and `timestamp`. Provider identifiers, SMPP sequence numbers, internal database identifiers, persistence enums, stack traces, and provider metadata are forbidden.

Each delivery request includes `X-Webhook-Timestamp` as Unix seconds and `X-Webhook-Signature` as `v1=<lowercase hex HMAC-SHA256>`. The signature input is the exact byte sequence `<timestamp>.<raw-request-body>`, signed with the active secret. Receivers must use constant-time comparison and reject timestamps more than five minutes outside their current time.

The outbound request timeout is 10 seconds. RC1 performs exactly one delivery attempt and implements no retry or retry scheduling. A webhook failure never rolls back message delivery persistence. Every attempt is recorded for 90 days; no archival policy is required in RC1. Retry scheduling is deferred to Phase 6A.

## Errors and compatibility

All errors use:

```json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "The request is invalid.",
    "details": {},
    "correlation_id": "01JEXAMPLE0000000000000000"
  }
}
```

The frozen codes are `AUTHENTICATION_FAILED` (401), `FORBIDDEN` (403), `INVALID_REQUEST` (400), `INVALID_RECIPIENT` (422), `INVALID_SENDER_ID` (422), `MESSAGE_TOO_LONG` (422), `IDEMPOTENCY_CONFLICT` (409), `MESSAGE_NOT_FOUND` (404), `WEBHOOK_CONFIGURATION_INVALID` (422), `RATE_LIMIT_EXCEEDED` (429), `SERVICE_UNAVAILABLE` (503), and `INTERNAL_ERROR` (500). `INSUFFICIENT_CREDITS` is excluded because wallet enforcement is not part of the approved send path. Messages and details are sanitized and never contain credentials, plaintext secrets, provider evidence, stack traces, or internal identifiers.

Existing clients currently use legacy field names, a response envelope, lowercase error codes, optional idempotency, and multiple internal status dimensions. RC1 implementation must preserve them through an explicitly approved additive adapter or a separately versioned/deprecated contract. It must not silently reinterpret requests or remove the collection endpoint. No compatibility promise is made by this document until that adapter and its sunset policy are approved.

## Approval blockers

- Idempotency retention and the migration from application-scoped conflict-only records.
- Bulk maximum size and per-item versus whole-request transaction policy.
- Default sender ownership and validation when `sender_id` is omitted.
- A zero-downtime API credential rotation runbook.
- The legacy-field compatibility and deprecation policy.
