Service Reference (`@wats/service`)
The @wats/service runtime-neutral standalone webhook/API service and its public API.
experimental · reviewed 2026-06-21
@wats/service exposes a runtime-neutral Request -> Response app composing the Graph client, webhook adapter, config profile shape, and WhatsApp facade. It is not a production server by itself: the CLI provides dry-run and credential-gated live wats serve wrappers, and a generated OpenAPI 3.1 document covers the routes below. Not implemented yet: Node/Docker packaging, production hosting.
Public API
import {
createWatsServiceApp,
createWatsServiceOpenApiDocument,
WatsServiceError,
type WatsServiceApp,
type WatsServiceConfig,
type WatsServiceOpenApiOptions
} from "@wats/service";createWatsServiceApp(config)
interface WatsServiceApp {
fetch(request: Request): Promise<Response>;
}Config:
interface WatsServiceConfig {
profile: WatsProfileConfig; // already validated by @wats/config
secrets: {
accessToken: string;
webhookVerifyToken: string;
webhookAppSecret: string;
serviceBearerToken: string;
};
transport?: Transport;
cryptoProvider?: CryptoProvider;
whatsapp?: { dispatch(update: unknown): unknown | Promise<unknown> };
persistence?: PersistenceStore;
enableGroupRoutes?: boolean;
}@wats/service does not read environment variables. You resolve env refs from @wats/config outside the service and pass explicit secret values in memory. The CLI live wrapper resolves env-secret refs from --env-file .env.local and process environment, then hands the resolved secrets to this package without changing the service API.
enableGroupRoutes is an explicit opt-in for Groups management routes. It defaults to false, so deployments that do not want Groups keep the pre-Groups route set.
Opt-in runtime env flags
When the service creates its default WhatsApp facade, two optional env flags can add local diagnostics:
| Flag | Effect | Boundary |
|---|---|---|
WATS_LOG_WEBHOOK_EVENTS=1 | logs normalized webhook update kind plus a PII-safe detail | no message text, raw webhook body, token, phone number, or WAMID |
WATS_ECHO_REPLY=1 | sends a demo text reply for inbound text updates | local testing only; not a bot framework |
If you inject your own whatsapp facade, these flags do nothing. The service package still receives explicit secrets; it does not read .env.local itself.
Routes
| Route | Method | Auth | Behavior |
|---|---|---|---|
/healthz | GET | none | Returns { ok: true, service: "wats" }. |
/readyz | GET | none | Returns { ok: true, service: "wats" } after construction. |
/status | GET | service bearer | Redacted operator status snapshot. Missing or invalid token returns 404, not 401. |
/metrics | GET | service bearer | Prometheus/OpenMetrics scrape endpoint. Missing or invalid token returns 404, not 401. |
/debug/diagnostics | GET | service bearer | Redacted support diagnostics snapshot. Missing or invalid token returns 404, not 401. |
/openapi.json | GET | none | Returns the generated OpenAPI 3.1 document. |
profile.webhook.path | GET | Meta verify token | Delegates to createWebhookAdapter. |
profile.webhook.path | POST | Meta signature | Delegates to createWebhookAdapter. |
${profile.service.apiPrefix}/messages/text | POST | service bearer | Sends a text message through Graph. |
${profile.service.apiPrefix}/messages | POST | service bearer | Sends a supported generic text, media, location, reaction, contacts, or interactive message body through Graph; group text/pin bodies are accepted only when enableGroupRoutes is true. |
${profile.service.apiPrefix}/messages | GET | service bearer | Lists the projected outbound message send-attempt page (newest-first) when a PersistenceStore is injected. |
${profile.service.apiPrefix}/messages/{messageId} | GET | service bearer | Returns a single projected message record by WhatsApp message id (wamid.*) when a PersistenceStore is injected. |
${profile.service.apiPrefix}/groups | GET, POST | service bearer | Opt-in (enableGroupRoutes) list/create Groups using the configured business phone-number id. |
${profile.service.apiPrefix}/groups/{groupId} | GET, POST, DELETE | service bearer | Opt-in get/update/delete a Group. |
${profile.service.apiPrefix}/groups/{groupId}/invite-link | GET, POST | service bearer | Opt-in get/reset a Group invite link. |
${profile.service.apiPrefix}/groups/{groupId}/participants | DELETE | service bearer | Opt-in remove up to 8 Group participants. |
${profile.service.apiPrefix}/groups/{groupId}/join-requests | GET, POST, DELETE | service bearer | Opt-in list/approve/reject Group join requests. |
Unknown routes return 404. Unsupported methods return 405 with an Allow header.
OpenAPI document
createWatsServiceOpenApiDocument(profile, options?) returns a plain-object OpenAPI 3.1 document for the current service routes. The app serves the same document at GET /openapi.json with JSON content type and no bearer requirement; method mismatch returns 405 with Allow: GET.
The document includes serviceBearerAuth only on the protected message routes, the /status and /debug/diagnostics operator routes, the /metrics scrape route, and, when enableGroupRoutes is true, the protected Groups routes. It never embeds raw service bearer, Graph access, app secret, verify token, or config env-var secret reference values.
Operator status
GET /status returns a redacted operator snapshot: package version, uptimeSeconds, graphApiVersion, a coarse serviceMode, a templated routes inventory, featureFlags, and a persistence health summary (or null when no store is injected). Route entries use :param placeholders, so no WABA id, phone-number id, or message id appears in the inventory.
/status differs from liveness and readiness. /healthz and /readyz answer "is this process alive?" and "is it ready to serve?" — they take no auth and return a fixed { ok, service } body. /status answers "what is this instance doing?", requires the service bearer token, and returns structured operator data. A missing or invalid token returns 404 (not 401) so the endpoint's existence is not disclosed to unauthenticated callers; the 404 body is identical to the catch-all. The snapshot never includes tokens, phone numbers, WABA ids, config file paths, raw webhook bodies, message text, or stack traces.
Metrics
GET /metrics serves Prometheus/OpenMetrics text exposition (Content-Type: text/plain; version=0.0.4). Same auth posture as /status: service bearer required, 404 (not 401) on a missing or mismatched token.
curl -s -H "Authorization: Bearer $WATS_SERVICE_BEARER_TOKEN" https://your-service/metrics# HELP http_requests_total Inbound HTTP requests to the service.
# TYPE http_requests_total counter
http_requests_total{method="POST",route="/webhooks/whatsapp",status_class="2xx"} 42
# HELP graph_operations_total Outbound Graph API calls by endpoint family and status class.
# TYPE graph_operations_total counter
graph_operations_total{endpoint_family="messages",outcome="success",status_class="2xx"} 17Metric families:
| Metric | Type | Labels |
|---|---|---|
http_requests_total | counter | route, method, status_class |
http_request_duration_seconds | histogram | route, status_class |
webhook_normalization_total | counter | update_kind, outcome |
graph_operations_total | counter | endpoint_family, status_class, outcome |
send_outcomes_total | counter | endpoint_family, outcome |
persistence_operations_total | counter | adapter, outcome — absent entirely when no PersistenceStore is injected |
route values are templated (/api/messages/:id, /api/groups/:groupId) — never a raw id. update_kind and endpoint_family values are enum-clamped to unknown when the underlying data doesn't match a known category, so a malformed webhook or an unexpected Graph path can never inject an arbitrary label value. Labels never carry phone numbers, WAMIDs, message text, or tokens.
Telemetry sink
WATS emits every internal telemetry event to an optional TelemetrySink passed in WatsServiceConfig.telemetrySink. If no sink is supplied the data still flows to the internal /metrics Prometheus registry. The sink seam lets you bridge to OpenTelemetry JS, Datadog, or any other backend without WATS taking a hard @opentelemetry/* runtime dependency.
import {
createWatsServiceApp,
OTEL_ATTR,
type TelemetrySink
} from "@wats/service";
class OtelBridge implements TelemetrySink {
private meter = otel.metrics.getMeter("wats.service");
incrementCounter(name: string, value: number, attributes: Record<string, string | number | boolean>) {
this.meter.createCounter(name).add(value, attributes);
}
recordHistogram(name: string, valueSeconds: number, attributes: Record<string, string | number | boolean>) {
this.meter.createHistogram(name).record(valueSeconds, attributes);
}
recordSpan?(name: string, start: Date, end: Date, attributes: Record<string, string | number | boolean>) {
// Optional span hook for request-scoped tracing.
// Not currently called by WATS; reserved for future instrumentation.
}
recordEvent?(name: string, attributes: Record<string, string | number | boolean>) {
// Optional event hook.
}
}
const app = createWatsServiceApp({
profile,
secrets,
telemetrySink: new OtelBridge()
});Methods are intentionally synchronous so the calling code never awaits a remote export, and errors thrown by a user sink are isolated and logged so telemetry cannot break request handling. Attribute keys follow OpenTelemetry conventions where a standard exists:
| Internal metric | Attribute keys | Notes |
|---|---|---|
http_requests_total | http.route, http.request.method, http.status_code, http.response.status.class | route is always templated |
http_request_duration_seconds | http.route, http.request.method, http.status_code, http.response.status.class | /metrics histogram is intentionally narrowed to route+status_class for cardinality; the sink receives the full attribute set. |
graph_operations_total | wats.graph.endpoint_family, http.status_code, http.response.status.class, wats.operation.outcome | endpoint family enum-clamped |
send_outcomes_total | wats.graph.endpoint_family, wats.operation.outcome | |
webhook_normalization_total | wats.webhook.update_kind, wats.operation.outcome | update kind enum-clamped |
persistence_operations_total | wats.persistence.adapter, wats.operation.outcome | absent when no PersistenceStore is injected |
Use OTEL_ATTR.httpRoute, OTEL_ATTR.operationOutcome, and the other exported constants instead of hard-coding the dotted strings. incrementCounter and recordHistogram are required; recordSpan and recordEvent are optional and are currently reserved for future request-scoped instrumentation. No @opentelemetry/* package appears in WATS runtime dependencies.
Point a Prometheus server at it with a scrape job:
scrape_configs:
- job_name: wats-service
scheme: https
bearer_token: "${WATS_SERVICE_BEARER_TOKEN}"
static_configs:
- targets: ["your-service:443"]
metrics_path: /metricsDebug diagnostics
GET /debug/diagnostics returns a bounded, redacted JSON support snapshot for operators and support bundles. Same auth posture as /status and /metrics: service bearer required, 404 (not 401) on a missing or mismatched token. The 404 body is identical to the catch-all.
The snapshot contains:
| Field | Content | Risk posture |
|---|---|---|
service, version | Package name and version | safe |
graphApiVersion | Configured Meta Graph API version | safe |
serviceMode | Coarse mode (webhook, webhook+persistence, webhook+groups, webhook+persistence+groups) | safe |
runtime | "bun" or "unknown" | safe |
routes | Templated route inventory with :id / :groupId placeholders | no raw ids |
featureFlags | { groupRoutes, persistence } booleans | safe |
persistence | Health summary: ok, backend, currentVersion, redactedLocation | no path or DSN |
metricFamilies | Names of registered metric families only | no values, no label sets |
recentErrors | Error class names with counts | no messages, no stack traces, no PII |
configShape | Config field names with redacted values ([REDACTED]) | no env var names, no values, no paths |
curl -s -H "Authorization: Bearer $WATS_SERVICE_BEARER_TOKEN" https://your-service/debug/diagnosticsLimits are defensive:
- No more than 10 distinct error classes are retained; when the cap is hit the oldest class is dropped.
- Error class names are truncated at 80 characters before counting.
persistence.backendis clamped tosqlite,postgres, orunknownso a misbehaving custom adapter cannot leak a path or DSN.- The snapshot never includes raw logs, stack traces, env values, config file paths, tokens, phone numbers, WABA ids, WAMIDs, message text, or webhook bodies.
This is not a pprof/heap endpoint and does not return live memory or CPU profiles. It is a structured facts-only endpoint meant for triage before fetching metrics and logs through the deployment's normal observability pipeline.
Service bearer auth
Message API routes require:
Authorization: Bearer ***Missing, malformed, or wrong credentials return 401 and do not echo the configured token. The service bearer token is never forwarded to Graph; Graph requests use secrets.accessToken through GraphClient.
Message routes
POST /messages/text
{
"to": "15551230000",
"text": "hello",
"previewUrl": true
}Validation: the root body must be a JSON object; to and text must be non-empty strings without control characters; previewUrl, when present, must be boolean. The service builds the WhatsApp text payload and uses the configured phone number id.
POST /messages
Accepts either the generic Graph-native text body:
{
"messaging_product": "whatsapp",
"to": "15551230000",
"type": "text",
"text": { "body": "hello" }
}or composer bodies converted through the SDK media builders before the Graph request is sent:
{
"type": "callPermissionRequest",
"to": "15551230000",
"bodyText": "May we call you?"
}{
"type": "image",
"to": "15551230000",
"mediaId": "1234567890",
"caption": "hello",
"replyToMessageId": "wamid.PARENT"
}Supported media type values: image, video, audio, document, sticker. Each media body must provide exactly one of mediaId or link. caption is accepted for image, video, and document bodies; filename for document bodies only. replyToMessageId maps to Graph context.message_id. Audio bodies accept voice: true, mapping to Graph audio.voice = true; omitting it preserves the standard audio payload.
Body type | Required fields | Optional fields | Graph mapping notes |
|---|---|---|---|
image / video / audio / document / sticker | exactly one of mediaId or link | caption (image/video/document), filename (document), replyToMessageId | replyToMessageId → context.message_id; audio accepts voice: true → audio.voice = true |
location | finite latitude / longitude in Graph-supported ranges | name, address, replyToMessageId | |
reaction | messageId, non-empty emoji | maps to a reaction payload | |
removeReaction | messageId | maps to a reaction payload with an empty emoji | |
contacts | non-empty contacts array (same camelCase contact inputs as the SDK composer) | ||
interactiveButtons / interactiveList / interactiveCtaUrl / interactiveProduct / interactiveProducts / interactiveCatalog / interactiveLocationRequest | see SDK builder inputs | replyToMessageId | map through the corresponding SDK builders |
callPermissionRequest | to, bodyText | footerText, replyToMessageId | type: "callPermissionRequest" emits interactive.type = "call_permission_request" and interactive.action.name = "call_permission_request" |
The route preserves the bearer boundary: the service bearer token authorizes the local route, is never forwarded to Graph, and builder/validation failures return 400 without echoing tokens or request secrets.
Message projection routes
When a PersistenceStore is injected, every successful outbound send (text and generic) records a local projection of the Graph send attempt: a MessageRecord keyed by the returned WhatsApp message id, plus an initial sent status event. Projection failures are swallowed and never break the send response — the caller still receives the Graph result.
GET /messages
Returns a newest-first page of projected messages. Query parameters:
limit— integer1..100, default50.cursor— opaque cursor: therowIdof the last item from the previous page. Passing it returns older rows.
{
"items": [
{
"rowId": "wats-msg-...",
"waMessageId": "wamid.HBgM...",
"direction": "outbound",
"fromPhone": null,
"toPhone": "15551230000",
"type": "text",
"status": "sent",
"graphMessageId": "wamid.HBgM...",
"createdAt": "2026-06-21T00:00:00.000Z",
"updatedAt": "2026-06-21T00:00:00.000Z"
}
],
"nextCursor": null
}nextCursor is the rowId of the last returned item when more rows may exist, otherwise null.
GET /messages/{messageId}
Returns a single MessageRecord by WhatsApp message id (wamid.*). Unknown ids return 404 not_found; unsafe id segments return 400.
Both routes return 503 persistence_not_configured when no PersistenceStore is injected. A GET /messages/text request is treated as the message-id lookup segment text only by method — the POST /messages/text route takes precedence for POST, and a GET to that exact path returns 405 (the text segment is reserved by the text-send route).
Groups routes (opt-in)
Pass enableGroupRoutes: true to expose Groups routes. Groups hang off profile.whatsapp.phoneNumberId, not the WABA id. Route inputs stay camelCase; mapping to Meta snake_case happens only at the Graph boundary.
POST /groupssendsPOST /<phoneNumberId>/groupswithsubject, optionaldescription, and optionaljoinApprovalMode.GET /groupssendsGET /<phoneNumberId>/groupswith optionallimit,after, andbeforequery values.GET|POST|DELETE /groups/{groupId}map to get, update, and delete on/<groupId>.GET|POST /groups/{groupId}/invite-linkmap toGET|POST /<groupId>/invite_link; reset is POST, not DELETE.DELETE /groups/{groupId}/participantsremoves up to 8 participants withwaIdsmapped toparticipants[].wa_id.GET|POST|DELETE /groups/{groupId}/join-requestslist, approve, or reject join requests. Reject is DELETE, not POST.
All Groups routes require the service bearer token, forward only the Graph access token to Graph, and return the same sanitized graph_request_failed envelope as message routes on Meta errors.
Webhook route
The configured webhook path delegates to @wats/http:
- GET challenge verification uses
secrets.webhookVerifyToken; - POST signature verification uses
secrets.webhookAppSecret; - normalized updates dispatch through the supplied
whatsappfacade-like object or a defaultWhatsAppfacade created from the service config.
Error taxonomy
Construction errors throw WatsServiceError with codes: invalid_config, invalid_profile, invalid_secrets, invalid_secret, invalid_path, invalid_transport, invalid_crypto_provider, invalid_whatsapp, invalid_persistence.
HTTP errors use JSON bodies:
{ "error": { "code": "unauthorized", "message": "Missing or invalid bearer token." } }400malformed request/body401missing or invalid service bearer token404route not found405method not allowed502Graph request failure (auth-class and uncategorized Meta errors). When the underlyingGraphApiErrorexposes sanitized Meta details, the body includesmetaCode,metaSubcode,metaType, andfbtraceIdalongside the stablegraph_request_failedcode. The service deliberately omits Meta's free-form error message because it may quote request/account identifiers; it never includes access tokens, app secrets, verify tokens, service bearer tokens, request bodies, orAuthorizationheaders.503Graph rate-limit failure. When Meta classifies the failure as rate limiting (GraphRateLimitError: codes 4, 80007, 130429, 131048, 131056, or HTTP 429), the service returns503with the same sanitizedgraph_request_failedbody. If Meta supplied aRetry-Afterheader, it is echoed verbatim on the503response so callers can back off correctly.
The 5xx boundary holds on every Graph failure: Meta's own 4xx never passes through as the service's status, because the caller did not send a bad request to the service — the cause surfaces inside the body. Each Graph failure also emits one warn-level JSON log line ({"event":"wats.graph.failure","metaCode":…,"metaSubcode":…,"metaType":…,"fbtraceId":…,"at":…}) carrying only sanitized structured identifiers, so container logs are diagnosable without probing Meta.
Example:
{
"error": {
"code": "graph_request_failed",
"message": "Graph request failed.",
"metaCode": 131030,
"metaType": "OAuthException",
"fbtraceId": "TRACE123"
}
}Persistence boundary
createWatsServiceApp(...) accepts an optional PersistenceStore; the service does not read database environment variables. The injected store must be the outbox-capable shape — construction validates it exposes:
migrate()andhealth()recordWebhookEvent(...)getServiceRequest(...)recordServiceRequest(...)enqueueOutboxItem(...)claimOutboxItems(...)markOutboxItemFailed(...)markOutboxItemSucceeded(...)recordMessage(...)appendMessageStatus(...)getMessage(...)listMessages(...)close()
Missing any method fails construction with WatsServiceError code invalid_persistence.
When persistence is injected:
- signed webhook POSTs are recorded by event key/hash and duplicate deliveries are acknowledged without redispatching the same update;
- message send routes honor
Idempotency-Key: a matching key/body hash replays the stored response; the same key with a different body returns409 idempotency_conflict; - successful outbound sends record a local message projection and an initial
sentstatus event, exposed read-only throughGET /messagesandGET /messages/{messageId}.
The outbox APIs are part of the accepted contract even though current message routes still send synchronously. claimOutboxItems(...) returns OutboxItem records with leaseId; pass that same leaseId to markOutboxItemFailed(...) / markOutboxItemSucceeded(...) so stale workers cannot complete a newer reclaimed lease.
The service must not log secrets or raw webhook bodies through persistence diagnostics; persistence failures must not expose database URLs, access tokens, app secrets, webhook verify tokens, service bearer tokens, message text, or raw webhook envelopes.
CLI wrappers
wats serve --config <path> --dry-run wraps this app in a local Bun process with synthetic in-memory secrets and a no-network Graph transport.
wats serve --config <path> --live --yes-live --env-file .env.local wraps this app for local live testing. The CLI owns the live guard, env-file parsing, secret resolution, and process lifecycle; @wats/service stays runtime-neutral and receives explicit in-memory values only.
For local webhook verification, run the live wrapper behind ngrok or an equivalent secure HTTPS tunnel. Meta will not accept plain HTTP or a bare local IP callback URL.
Deployment
The repo ships a Railway-targeted root Dockerfile that wraps the implemented wats serve contract. There is still no supported Compose file, container image release, registry workflow, or production hosting contract.
Non-goals
Not implemented: a full Meta Graph API OpenAPI document, live Meta credential checks, background queue/outbox workers, TLS, rate limiting, production container publication, or message schemas beyond the current text/media/location/contacts/reaction/interactive slices.