wats.sh
Reference

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:

FlagEffectBoundary
WATS_LOG_WEBHOOK_EVENTS=1logs normalized webhook update kind plus a PII-safe detailno message text, raw webhook body, token, phone number, or WAMID
WATS_ECHO_REPLY=1sends a demo text reply for inbound text updateslocal 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

RouteMethodAuthBehavior
/healthzGETnoneReturns { ok: true, service: "wats" }.
/readyzGETnoneReturns { ok: true, service: "wats" } after construction.
/statusGETservice bearerRedacted operator status snapshot. Missing or invalid token returns 404, not 401.
/metricsGETservice bearerPrometheus/OpenMetrics scrape endpoint. Missing or invalid token returns 404, not 401.
/debug/diagnosticsGETservice bearerRedacted support diagnostics snapshot. Missing or invalid token returns 404, not 401.
/openapi.jsonGETnoneReturns the generated OpenAPI 3.1 document.
profile.webhook.pathGETMeta verify tokenDelegates to createWebhookAdapter.
profile.webhook.pathPOSTMeta signatureDelegates to createWebhookAdapter.
${profile.service.apiPrefix}/messages/textPOSTservice bearerSends a text message through Graph.
${profile.service.apiPrefix}/messagesPOSTservice bearerSends 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}/messagesGETservice bearerLists the projected outbound message send-attempt page (newest-first) when a PersistenceStore is injected.
${profile.service.apiPrefix}/messages/{messageId}GETservice bearerReturns a single projected message record by WhatsApp message id (wamid.*) when a PersistenceStore is injected.
${profile.service.apiPrefix}/groupsGET, POSTservice bearerOpt-in (enableGroupRoutes) list/create Groups using the configured business phone-number id.
${profile.service.apiPrefix}/groups/{groupId}GET, POST, DELETEservice bearerOpt-in get/update/delete a Group.
${profile.service.apiPrefix}/groups/{groupId}/invite-linkGET, POSTservice bearerOpt-in get/reset a Group invite link.
${profile.service.apiPrefix}/groups/{groupId}/participantsDELETEservice bearerOpt-in remove up to 8 Group participants.
${profile.service.apiPrefix}/groups/{groupId}/join-requestsGET, POST, DELETEservice bearerOpt-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"} 17

Metric families:

MetricTypeLabels
http_requests_totalcounterroute, method, status_class
http_request_duration_secondshistogramroute, status_class
webhook_normalization_totalcounterupdate_kind, outcome
graph_operations_totalcounterendpoint_family, status_class, outcome
send_outcomes_totalcounterendpoint_family, outcome
persistence_operations_totalcounteradapter, 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 metricAttribute keysNotes
http_requests_totalhttp.route, http.request.method, http.status_code, http.response.status.classroute is always templated
http_request_duration_secondshttp.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_totalwats.graph.endpoint_family, http.status_code, http.response.status.class, wats.operation.outcomeendpoint family enum-clamped
send_outcomes_totalwats.graph.endpoint_family, wats.operation.outcome
webhook_normalization_totalwats.webhook.update_kind, wats.operation.outcomeupdate kind enum-clamped
persistence_operations_totalwats.persistence.adapter, wats.operation.outcomeabsent 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: /metrics

Debug 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:

FieldContentRisk posture
service, versionPackage name and versionsafe
graphApiVersionConfigured Meta Graph API versionsafe
serviceModeCoarse mode (webhook, webhook+persistence, webhook+groups, webhook+persistence+groups)safe
runtime"bun" or "unknown"safe
routesTemplated route inventory with :id / :groupId placeholdersno raw ids
featureFlags{ groupRoutes, persistence } booleanssafe
persistenceHealth summary: ok, backend, currentVersion, redactedLocationno path or DSN
metricFamiliesNames of registered metric families onlyno values, no label sets
recentErrorsError class names with countsno messages, no stack traces, no PII
configShapeConfig 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/diagnostics

Limits 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.backend is clamped to sqlite, postgres, or unknown so 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 typeRequired fieldsOptional fieldsGraph mapping notes
image / video / audio / document / stickerexactly one of mediaId or linkcaption (image/video/document), filename (document), replyToMessageIdreplyToMessageIdcontext.message_id; audio accepts voice: trueaudio.voice = true
locationfinite latitude / longitude in Graph-supported rangesname, address, replyToMessageId
reactionmessageId, non-empty emojimaps to a reaction payload
removeReactionmessageIdmaps to a reaction payload with an empty emoji
contactsnon-empty contacts array (same camelCase contact inputs as the SDK composer)
interactiveButtons / interactiveList / interactiveCtaUrl / interactiveProduct / interactiveProducts / interactiveCatalog / interactiveLocationRequestsee SDK builder inputsreplyToMessageIdmap through the corresponding SDK builders
callPermissionRequestto, bodyTextfooterText, replyToMessageIdtype: "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 — integer 1..100, default 50.
  • cursor — opaque cursor: the rowId of 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 /groups sends POST /<phoneNumberId>/groups with subject, optional description, and optional joinApprovalMode.
  • GET /groups sends GET /<phoneNumberId>/groups with optional limit, after, and before query values.
  • GET|POST|DELETE /groups/{groupId} map to get, update, and delete on /<groupId>.
  • GET|POST /groups/{groupId}/invite-link map to GET|POST /<groupId>/invite_link; reset is POST, not DELETE.
  • DELETE /groups/{groupId}/participants removes up to 8 participants with waIds mapped to participants[].wa_id.
  • GET|POST|DELETE /groups/{groupId}/join-requests list, 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 whatsapp facade-like object or a default WhatsApp facade 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." } }
  • 400 malformed request/body
  • 401 missing or invalid service bearer token
  • 404 route not found
  • 405 method not allowed
  • 502 Graph request failure (auth-class and uncategorized Meta errors). When the underlying GraphApiError exposes sanitized Meta details, the body includes metaCode, metaSubcode, metaType, and fbtraceId alongside the stable graph_request_failed code. 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, or Authorization headers.
  • 503 Graph rate-limit failure. When Meta classifies the failure as rate limiting (GraphRateLimitError: codes 4, 80007, 130429, 131048, 131056, or HTTP 429), the service returns 503 with the same sanitized graph_request_failed body. If Meta supplied a Retry-After header, it is echoed verbatim on the 503 response 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() and health()
  • 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 returns 409 idempotency_conflict;
  • successful outbound sends record a local message projection and an initial sent status event, exposed read-only through GET /messages and GET /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.

On this page