Skip to main content

tai42-channel-whatsapp

License: Apache 2.0

A Meta WhatsApp API channel plugin for the TAI ecosystem. It delivers an ask question to a human on WhatsApp through the Cloud (Graph) API and bridges the human's reply back into the interactions store — so an agent can reach a person out-of-band instead of only showing the question in the Studio inbox. It implements the tai42_contract.channels.Channel protocol and registers under the name "whatsapp". It is for numbers hosted directly on Meta's Cloud API (no BSP/Twilio in front); the Twilio-hosted path is the sibling tai42-channel-twilio.

The TAI ecosystem

TAI is an open-source runtime for MCP tools, agents, and workflows. A Channel is "how a question reaches a human" — a pluggable deliverer the runtime resolves by name when ask is called with channel=.... This package is one such deliverer (WhatsApp API); siblings back the same contract with Twilio, Telegram, or Slack. The ecosystem is open-ended: any package can back the same contract, so this repo is this plugin's own full doc home, and the documentation site covers the platform-level story:

Its only tai-* dependencies are tai42-contract (the Channel protocol, ChannelDelivery, ChannelDeliveryError, and the tai42_app handle) and tai42-kit[redis] (HttpxClient, RedisClient, TaiBaseSettings, and the settings cache). Beyond those it depends on httpx, starlette, and pydantic / pydantic-settings. There is no Meta SDK: the send is one Bearer-auth JSON POST over httpx, and webhook signature validation is a few lines of stdlib hmac/hashlib.

Install

Requires Python 3.13+. Install from PyPI into the environment that runs the server:

uv add tai42-channel-whatsapp

Or from source — clone this repo and add it as an editable dependency; the tai42-* dependencies resolve in-tree from the workspace.

git clone https://github.com/tai42ai/tai42   # next to your app checkout
cd /path/to/your/app
uv add --editable ../tai42/plugins/channel-whatsapp

Discovery

The runtime discovers this plugin through the manifest's channel_modules key:

channel_modules: ["tai42_channel_whatsapp"]

At app load the runtime imports every module under the package, and register.py fires the registrations as its import side-effect: the "whatsapp" channel on tai42_app.channels, and — via the inbound import — the unauthenticated webhook route on tai42_app.http. A bare import tai42_channel_whatsapp registers nothing — the package is library-safe; only the register module carries the side-effect.

Configuration

Settings are read from the CHANNEL_WHATSAPP_ environment group (see WhatsAppSettings / WhatsAppRedisSettings):

Env var Required Meaning
CHANNEL_WHATSAPP_ACCESS_TOKEN yes Graph API access token (SecretStr) — the Bearer credential for the send
CHANNEL_WHATSAPP_APP_SECRET yes Meta app secret (SecretStr) — the X-Hub-Signature-256 HMAC key for inbound webhooks
CHANNEL_WHATSAPP_VERIFY_TOKEN yes Shared token (SecretStr) echoed during Meta's GET webhook verification handshake
CHANNEL_WHATSAPP_DEFAULT_PHONE_NUMBER_ID for ask The phone_number_id messages are sent FROM when no sender identity is routed
CHANNEL_WHATSAPP_WABA_ID for forms The WhatsApp Business Account id that owns Flows — a form ask and an ask-less form notification are rendered as a WhatsApp Flow created and published under this WABA, and the notify-form schema cache is keyed under it. Required only on the form paths
CHANNEL_WHATSAPP_FLOW_PRIVATE_KEY for reacting forms The business RSA-2048 private key (PEM, SecretStr) — the Flow data endpoint decrypts each reacting-form request's AES key with it. Required only to publish/serve a form that reacts while open; a static form never touches it
CHANNEL_WHATSAPP_FLOW_PRIVATE_KEY_PASSPHRASE no Passphrase protecting CHANNEL_WHATSAPP_FLOW_PRIVATE_KEY when the PEM is encrypted (SecretStr)
CHANNEL_WHATSAPP_FLOW_ENDPOINT_URI for reacting forms The public HTTPS URL of this plugin's Flow data endpoint ({public base URL}/api/channels/whatsapp/flow-data), configured on a reacting Flow at create time so Meta routes its data-exchange requests back here
CHANNEL_WHATSAPP_ALLOWED_RECIPIENTS for cold templates Whitelist of wa_ids a template send may reach when the recipient is not a known contact — comma-separated or a JSON list. Freeform sends are not fenced by it
CHANNEL_WHATSAPP_TEMPLATE_CONTACT_WINDOW_DAYS no (30) Rolling "seen within N days" window admitting a template send to a wa_id the inbound webhook has messaged from; 0 disables known-contact tracking (allowlist-only templates)
CHANNEL_WHATSAPP_API_BASE_URL no Graph API origin + pinned version (default https://graph.facebook.com/v23.0)
CHANNEL_WHATSAPP_REDIS_URL yes Correlation + known-contact store (plugin-owned Redis)
CHANNEL_WHATSAPP_REDIS_MAX_CONNECTIONS … no The rest of the kit RedisConnectionSettings fields, same names under this prefix
CHANNEL_WHATSAPP_HTTP_TIMEOUT_SECONDS no (30.0) Outbound send + answer-forward timeout, seconds
CHANNEL_WHATSAPP_DEDUPE_TTL no (172800) Seen-wamid replay-guard window, seconds

One credential (ACCESS_TOKEN + APP_SECRET) serves many phone_number_ids: a bridge reply is sent from the exact phone_number_id that received the inbound message, while an ask delivery is sent from CHANNEL_WHATSAPP_DEFAULT_PHONE_NUMBER_ID. Recipient policy is split by what Meta itself fences. Freeform sends (questions, replies, media) reach any requested wa_id — Meta's own 24-hour customer-service window is the fence, so a send to a number outside it is rejected by Meta (error 131047) and raises. Template sends are the one send Meta delivers cold, so they keep an operator fence: the recipient must be on CHANNEL_WHATSAPP_ALLOWED_RECIPIENTS or be a known contact — a (phone_number_id, wa_id) pair the inbound webhook has seen within CHANNEL_WHATSAPP_TEMPLATE_CONTACT_WINDOW_DAYS days; a cold, unlisted number raises. A recipient is always caller-supplied — there is no default recipient, so a recipientless request raises. Secrets live only in the environment.

Two steps happen out-of-band (the plugin never mutates Meta app configuration at startup):

  1. In the Meta App dashboard, point the WhatsApp webhook callback URL at {public base URL}/api/channels/whatsapp/inbound and set the verify token to CHANNEL_WHATSAPP_VERIFY_TOKEN. Meta issues a GET handshake the route answers by echoing hub.challenge.
  2. Subscribe the app to the messages webhook field so message and delivery-status events reach the same POST endpoint.

How a human answers

A text question arrives as a normal WhatsApp message; the human just replies — no code to quote, no prefix. A select question renders natively: a few short options become tappable reply buttons, more options become an interactive list, and past those platform caps it falls back to a numbered plaintext list. A tap answers by a question-bound id that maps back to the exact option text; the human may always type an option instead. A form question renders as an in-chat WhatsApp Flow — one screen of typed fields the human fills and submits: a string becomes a text field, a string with a short enum a radio group (a longer enum, or a per-send option list, a dropdown), an array of strings a checkbox group (multiple choice), a boolean an opt-in toggle, and an integer/number a numeric text field. A string with format: date that carries platform date constraints (minDate/maxDate, unavailableDates, or a two-field range pairing) renders a bounded CalendarPicker (an unconstrained date stays a plain date picker); a declared date range is drawn as two scalar date fields and its ordering/span is enforced at the single answer facet, so the submitted answer is always the two date values, never a combined object. A page's display blocks (heading/body/ image, each static or a reaction-filled slot) render ahead of its inputs, a review page shows a generic readback of the entered values, and a field with a visibleWhen predicate is shown/hidden by a client-side If. The Flow is created and published once per distinct answer schema (cached by a schema hash under CHANNEL_WHATSAPP_WABA_ID) and reused; the completed form returns as an nfm_reply, its values coerced to the schema's types before the answer object is forwarded. When the callback door rejects a forwarded form answer (400 — a schema rule the Flow could not enforce, e.g. a minimum or pattern), a completed Flow has no re-reply surface, so the channel re-sends a fresh Flow for the same interaction: same flow token, the cached flow id, and a body that repeats the question with the door's error line (which names the failing field). This is bounded — after a fixed number of rejections the channel stops re-sending, tells the participant once the form could not be processed, and lets the ask time out on its own deadline. The platform validates the answer schema against this subset when the question is asked — before the question is stored — so an out-of-subset schema (nested objects, arrays of non-strings, unions, unknown types) never reaches this send; the Flow mapping rejects one defensively too. Correlation is fully out-of-band: any reply (typed, tapped, or a submitted form) from the recipient resolves the (phone_number_id, wa_id) pair's pending question, so one question can be pending per pair at a time; a second concurrent one is rejected loudly.

Forms that react while open

A form whose ask names a reaction handler and declares reaction triggers (a field change, a page advance, or the submission) publishes as an endpoint-driven Flow: its reacting controls fire encrypted data_exchange requests to this plugin's Flow data endpoint ({public base URL}/api/channels/whatsapp/flow-data), which decrypts them, calls the platform's one reaction chokepoint, and returns the resulting form update (set values, replaced choice lists, per-field errors, or a computed display slot) back to the open Flow — the plugin only translates the vendor wire to and from that chokepoint and holds no logic of its own. A reaction that fails or times out surfaces as a generic error on the Flow and a loud server log, never a stale value. A reacting form cannot publish or be served without its key material, so the delivery refuses loudly (it never silently falls back to a non-reacting flow) when it is missing.

The vendor's data endpoint needs a business key pair provisioned once per sending number (the plugin never mutates Meta configuration at startup):

  1. Generate an RSA-2048 key pair (PKCS#8 PEM).
  2. Set the private key as the secret CHANNEL_WHATSAPP_FLOW_PRIVATE_KEY (and, if the PEM is encrypted, CHANNEL_WHATSAPP_FLOW_PRIVATE_KEY_PASSPHRASE). It never leaves the environment; a fork configures its own.
  3. Set CHANNEL_WHATSAPP_FLOW_ENDPOINT_URI to the public URL of the /api/channels/whatsapp/flow-data route.
  4. Register the public key for the sending number — run tai42_channel_whatsapp.provisioning.provision_flow_encryption(), which derives the public key from the configured private key and registers it via the Graph API (re-run after a key rotation).
  5. Ensure the endpoint is publicly reachable and healthy (Meta pings it).

The data endpoint authenticates each request with X-Hub-Signature-256 over the raw body (the same app secret as the webhook; a mismatch is HTTP 432), decrypts the AES-128-GCM payload with the private key (an undecryptable body is 421), and answers an unknown/expired flow token with 427.

An agent notification (notify_user) advertises the full capability set — supports_media_notifications, supports_location_notifications, supports_template_notifications, supports_interactive_notifications, and supports_form_notifications — so a notification may carry the full outbound vocabulary:

  • Media — each file item sends as its own native message: an image, document (with caption/filename), video (with caption), or audio (voice/clip; the Cloud API audio object carries no caption/filename); a link item is appended to the body text as a label: url line.
  • Options (list[Option], a typed union, never bare strings) — a ReplyOption renders as a native reply button (or a list row past the button caps / for a described option), sending its authored id on the wire when set (echoed back on tap) else a minted index; a tap enters the conversation as a visitor message. A lone LinkOption renders as a cta_url interactive (one URL button); mixed reply+link or multiple links append the link(s) to the body as label: url lines (WhatsApp has no multi-URL button).
  • Sections (list[OptionSection]) — a multi-section interactive list, each row carrying an optional description secondary line.
  • Header + footer — a media header (image/video/document) rides the interactive header (an audio header is sent as its own message ahead of it); a text footer rides the interactive footer. Both require options/sections.
  • Location (LocationElement) — a native location message (a pin with an optional name/address).
  • Template — for a send outside the 24-hour window, a pre-approved ChannelTemplate by name: its header_media (image/video/document), body_parameters (positional body-text fills), and buttons (per-button quick-reply payload / url suffix) map onto the template-message components.
  • Ask-less form (schema) — an in-chat WhatsApp Flow (see below).

template, a choice surface, and schema are mutually exclusive on one notification; options and sections are mutually exclusive with each other; media and location may combine with a choice surface.

An ask-less form notification renders exactly like a form ask — any media first, then a WhatsApp Flow whose body is the message — but stores no correlation: the flow token is minted in the tai42-nf: namespace (tai42-nf:{schema hash}:{random}), and the inbound webhook routes an nfm_reply by that prefix before any pending-question lookup, so a submission enters the conversation as a structured participant message (rendered label: value text plus the structured copy) and can never answer — or disturb — a question pending on the same pair. The answer schema is cached durably beside the published-flow id under the schema hash; a reply carries only the hash, so a lost cache entry cannot be repopulated from it — the reply then degrades to its raw (uncoerced) values, and is still accepted. Like every freeform send, a form notification delivers only inside Meta's 24-hour customer-service window — out of window the send fails loudly (error 131047), synchronously or as a failed status; it is never silently downgraded to a template.

A confirm or external question arrives as a tappable link and is answered in the browser via the callback door — no WhatsApp reply is expected or matched, and it never consumes the pair.

Inbound entry parameters

When an inbound message enters the conversation bridge as a fresh turn (a message with no pending question to answer), the channel forwards a set of opaque entry parameters alongside the turn text. They ride verbatim to a tool target's payload under its own params key (payload["params"]) — the platform attaches no meaning and no trust; a channel-agnostic consumer opts into whichever keys it understands. This is the channel's public inbound contract:

params key Set when Value
reply_id An interactive reply button / list row is tapped but is not an answer to a pending ask (no ask, or a stale/other id) The question-bound wire id the outbound carried (button_reply.id / list_reply.id)
reply_description The tapped item is a list row that carried a secondary description line list_reply.description
button_payload A template quick-reply button is tapped (a button-type message) The developer-defined button.payload behind the visible button.text
context_message_id The message quotes / replies to an earlier one context.id (the quoted wamid)
referral_source_url The message enters via click-to-WhatsApp / QR (referral) referral.source_url
referral_source_id " referral.source_id
referral_source_type " referral.source_type (e.g. ad, post)
referral_ctwa_clid " referral.ctwa_clid (the click-to-WhatsApp click id)
referral_headline " (when present) referral.headline
referral_body " (when present) referral.body
media_kind An inbound media message (image/document/audio/video/sticker) The wire type
media_id " The served media id the bytes were ingested to (see the inbound media note below)
media_mime_type " The ingested media's sniffed-and-agreed mime type
media_sha256 " The ingested media's sha256 (content integrity)
media_size " The ingested media's actual byte size
media_filename An inbound document The sanitised document.filename
media_voice An inbound audio that is a voice note "true" (audio.voice)
sticker_animated An animated sticker "true" (sticker.animated)
reaction_emoji An inbound reaction (absent = a removed reaction) reaction.emoji
reaction_message_id An inbound reaction The wamid it was applied to (reaction.message_id)
contacts_count An inbound contacts message The number of shared contact cards
contacts " The raw contacts array as compact JSON (dropped when over the per-value cap; contacts_count still rides)

Participant media and location and reactions and contacts bridge as turns. The caption of a media message becomes the turn text (a faithful [image] / [document: file] / [voice message] / … placeholder when caption-less); an inbound location lands as a typed LocationElement on the turn's location (a machine-consumable field, not a param), with the place name/coordinates as the text.

Inbound media. WhatsApp inbound media arrives as a Graph media id; the channel looks up the object's short-lived, Bearer-authenticated lookaside URL, streams the bytes, and ingests them through the platform's app.media.ingest_media chokepoint, which caps, sniffs, stores, and mints a served {MEDIA_ROUTE_PREFIX}{id} reference. The bridged turn then carries both a typed attachments entry (a MediaItem at the served URL) and the parity media_* params off that one ingest. A transient fetch fault (a 5xx, a timeout, a torn read) raises so the webhook 5xxs and Meta redelivers (idempotent through the per-wamid dedupe); a permanent fault rejects with a participant notice — too_large (over the operator's per-kind cap), unsupported_type (the sniffed content is not on its allowlist), or could_not_receive (the media is gone, the URL is guarded, or no store is configured) — and, when a caption rode with it, bridges the caption as a text-only turn so no message is lost. The Bearer rides the fetch calls only, never a log, param, or error string.

Params ride only on the bridge path. A tap or quick-reply that answers a pending question does not surface them: the answer path forwards {"answer": …} to the callback door — a seam that carries no params — and a tap's id is already consumed there to select the option. Values are transport-bounded (per the platform's entry-param limits — count, key charset, value length, total size); an individual value over the per-value cap is dropped (never truncated), and in the rare case the aggregate still overflows a bound the whole set is dropped and the turn bridges without it — a participant message is never lost to a params bound.

Meta inbound error notices (e.g. an unsupported message type the participant sent, carried as an errors[] array) are logged at warning with the detail and are never bridged as participant turns.

Delivery statuses

WhatsApp reports delivery asynchronously: a send returns 2xx with a wamid, and a later statuses webhook carries sent/delivered/read/failed. The same POST endpoint records those receipts through the interactions facet — failed (e.g. a message outside the 24-hour session window, error 131047) marks the answer failed loudly; sent/delivered confirm it; read is informational and ignored.

A wamid the conversation bridge does not own is not a dead end: it may be a notify_user send (which runs no conversation record). The webhook then resolves the wamid through the send-outcome receipt index and, on a hit, posts a delivery_receipt event onto the send's originating monitoring trace, nested under its send span — ERROR for a failed receipt (carrying the provider errors), informational otherwise. This is observability only: it annotates the originating trace, never re-sending or failing the run. Only a genuine miss — neither the bridge nor any such send owns the wamid — keeps the untracked-message log; either way the status is acknowledged, never retried.

Security

  • Inbound requests authenticate via X-Hub-Signature-256: sha256= + hex(HMAC-SHA256(app_secret, raw body)), validated fail-closed with a constant-time compare before the body is parsed. A missing/empty app secret is an operator error that raises loudly (logged 500) — never a soft 401 that reads like a bad signature.
  • The GET verification handshake echoes hub.challenge only when hub.verify_token matches the configured token under a constant-time compare.
  • Meta's signature scheme carries no timestamp, so a captured request validates forever; the wamid dedupe window (48h default) plus HTTPS are the replay guards.
  • The access token, app secret, and verify token are SecretStr — never in a repr, log line, or traceback; the plaintext is read only at the Bearer-auth and HMAC seams.
  • The unauthenticated route bounds its body read (1 MiB → 413) before any signature work.

Limits

Limit Consequence
One pending question per (phone_number_id, wa_id) pair A second concurrent ask over this channel fails loudly with PendingQuestionExistsError while the first is unanswered/unexpired
Freeform sends need the 24h window A freeform send (question, reply, media) outside the human's 24-hour session window is rejected by Meta (error 131047), synchronously as a delivery error or asynchronously as a failed status. A template is the only send Meta accepts outside the window
Single send attempt per part A transient Cloud API outage fails the send instead of retrying (no idempotency key → a blind retry risks double-messaging). A multi-part media send that fails on the Nth part raises naming the wamids already delivered
Form schema is a flat object subset A form ask's answer schema is a top-level object whose properties are string, string+enum, an array of strings (a multiple-choice field), boolean, integer, or number. The platform enforces this subset at ask-time, so nested objects, arrays of non-strings, and oneOf/anyOf are refused before the question is stored; the Flow mapping refuses them defensively too, and additionally rejects a property named flow_token — Meta reserves that key on the Flow response, so a field of that name is unanswerable on this channel. A reacting form additionally reserves the property names tai42_event, tai42_field, and tai42_page (its data-exchange event markers)
Template audio header unsupported A ChannelTemplate maps header_media (image/video/document), body_parameters, and quick-reply/url buttons onto the template-message components. An audio header_media has no Cloud API template representation and is refused loudly (ChannelInputError); an audio interactive header on a notification is instead sent as its own message ahead of the interactive
No timestamp in Meta's signature scheme Replay of a captured request validates forever for that body; wamid dedupe (48h default window) + HTTPS are the guards (a Meta protocol property)

Development

uv venv --python 3.13
uv pip install --no-sources --group dev --editable .
uv run --no-sync pytest --cov --cov-report=term-missing
uv run --no-sync ruff check .
uv run --no-sync ruff format --check .
uv run --no-sync pyright

The live integration suite (pytest -m integration) sends real messages via the WhatsApp API and runs only when the CHANNEL_WHATSAPP_* credentials are present in the environment; it skips cleanly otherwise.

License

Apache-2.0. See LICENSE and NOTICE.

Correlation surface (2.0)

Since 2.0 inbound answers resolve through the platform's shared inbound-answer ladder: the plugin exposes its correlation store over the contract's CorrelationStore port (reserve / peek / release) plus a transport ack, and the skeleton owns the forward / retry-in-place / bridge ladder. The plugin-local pop/restore correlation helpers from 1.x are gone.

Metadata

Release files for tai42-channel-whatsapp 6.5.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tai42-channel-whatsapp 6.5.2
File Size Uploaded
tai42_channel_whatsapp-6.5.2.tar.gz 191.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for tai42-channel-whatsapp 6.5.2
File Interpreter ABI Platform
tai42_channel_whatsapp-6.5.2-py3-none-any.whl Python 3 none any Details

Total release size: 314.5 kB

Release files / tai42_channel_whatsapp-6.5.2.tar.gz

Download URL tai42_channel_whatsapp-6.5.2.tar.gz
Size 191.1 kB
Tags Source
SHA-256 checksum
How to use checksums
0d8dd24cd830f01829d031d5391d701f5914b7986fd4d343aa7920f3e638f582
BLAKE2b-256 checksum
How to use checksums
51c0d4250923841d4ec4252f096d5f1cf80e53b90d87be9b31653d286c1d73d6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release files / tai42_channel_whatsapp-6.5.2-py3-none-any.whl

Download URL tai42_channel_whatsapp-6.5.2-py3-none-any.whl
Size 123.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
228e79ac32627d37721ed25e1a2c79503e00ea6fde047c7e0a30b4e4ee01e9be
BLAKE2b-256 checksum
How to use checksums
6b302fc7ddf8c501186d6d5388f1760567882937e7daf282858c500d8a6921c1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release history Release notifications | RSS feed

8.0.6

2 release files

8.0.5

2 release files

8.0.4

2 release files

8.0.3

2 release files

8.0.2

2 release files

8.0.1

2 release files

8.0.0

2 release files

7.0.1

2 release files

7.0.0

2 release files

6.5.3

2 release files

This release

6.5.2 This release

2 release files

6.5.1

2 release files

6.5.0

2 release files

6.4.2

2 release files

6.4.1

2 release files

6.4.0

2 release files

6.3.1

2 release files

6.3.0

2 release files

6.2.2

2 release files

6.2.1

2 release files

6.2.0

2 release files

6.1.2

2 release files

6.1.1

2 release files

6.1.0

2 release files

6.0.2

2 release files

6.0.1

2 release files

6.0.0

2 release files

5.0.2

2 release files

5.0.1

2 release files

5.0.0

2 release files

4.0.10

2 release files

4.0.9

2 release files

4.0.8

2 release files

4.0.7

2 release files

4.0.5

2 release files

4.0.4

2 release files

4.0.3

2 release files

4.0.2

2 release files

4.0.1

2 release files

4.0.0

2 release files

3.3.1

2 release files

3.3.0

2 release files

3.2.1

2 release files

3.2.0

2 release files

3.1.0

2 release files

3.0.1

2 release files

3.0.0

2 release files

2.3.0

2 release files

2.2.1

2 release files

2.2.0

2 release files

2.1.0

2 release files

2.0.0

2 release files

1.3.2

2 release files

1.3.1

2 release files

1.3.0

2 release files

1.2.1

2 release files

1.2.0

2 release files

1.1.1

2 release files

1.1.0

2 release files

1.0.2

2 release files

1.0.1

2 release files

1.0.0

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.3

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page