meta-whatsapp-rs architecture
This is the spec. Code that disagrees with it is a bug in one of the two; fix whichever is wrong, in the same PR. For task-oriented integration walkthroughs (Meta setup, onboarding, inbox, OTP, production), see the integrator guides.
Goals
A Rust toolkit for Meta's WhatsApp Business Platform that serves three concrete products:
- E-commerce marketing — templates (create, get approved, send), the Marketing Messages API, In-App Signup opt-ins, catalogs and product messages, delivery/read analytics, respecting opt-outs (
131050) and per-user marketing limits (131049). - CMS in-app chat — each merchant onboards their own WhatsApp number through Embedded Signup; their customers' messages arrive by webhook, are stored per conversation, streamed live to the merchant's inbox UI, and answered with the merchant's business token, inside the 24-hour customer service window (templates outside it).
- Authentication — OTP over authentication templates (copy code, one-tap, zero-tap), issued, stored hashed, verified with attempt limits.
Layout
crates/
meta-whatsapp-core error tree, ids, config, ports (traits). No I/O, no runtime.
meta-whatsapp-client Graph API client; one module per endpoint family.
meta-whatsapp-webhooks verify, parse, normalize, dedup, dispatch; axum router (feature).
meta-whatsapp-adapters port implementations: reqwest, memory/Postgres/SQLite/Redis stores, sinks.
meta-whatsapp-typst Typst → PDF/PNG for document and image messages.
meta-whatsapp-bot bot framework over webhooks: commands, guards, cooldowns, middleware,
compile-time plugins, Markdown → WhatsApp formatting, paced broadcasts.
meta-whatsapp-rs facade: re-exports, prelude, `client(token)`, the CMS inbox,
feature flags, runnable examples. What integrators depend on.
meta-whatsapp-server-core the service's framework-free core: its domain, authorization,
error model as data, and the ports its backends implement.
meta-whatsapp-server-store-memory the service's memory backend (development).
meta-whatsapp-server-store-postgres the service's Postgres backend and its migrations.
meta-whatsapp-server-http the service's HTTP layer: listeners, authentication before
the body, the error renderer, Meta's webhook, media, operations.
meta-whatsapp-server-api-axum the service's /v1 routes on axum and their OpenAPI document.
meta-whatsapp-server the HTTP service (a binary composing the four above by its Cargo
features, on the facade and the core): tenants, keys, the /v1 API
for apps not written in Rust. See "Service" below.
.xtask repo automation (`cargo xtask meta-docs`): a workspace of its own,
with its own Cargo.lock, excluded from the root one, so its ureq
(rustls with `ring`) never enters a library or test build.
tools/docs-mcp the docs MCP server (`just docs-mcp`): the docs and skills over MCP's
stdio transport. A binary on serde_json alone, a member of the
root workspace but not a default one; nothing depends on it.Dependency rule: every library crate depends on meta-whatsapp-core; no library crate depends on meta-whatsapp-rs; binaries may, and the service's crates do: they depend on meta-whatsapp-rs and on each other, never on another crate of the library, reaching axum and sqlx through the facade's re-exports, as an outside integrator would; no library crate depends on a service crate; meta-whatsapp-client and meta-whatsapp-webhooks never depend on each other or on meta-whatsapp-adapters (except as a dev-dependency for tests); meta-whatsapp-bot sits above both and depends on core, client and webhooks only, never on the adapters (dev-dependency aside) or the facade, which re-exports it behind its bot feature. An adapter never leaks its library's types through a port.
Ports (meta-whatsapp-core)
| Port | Methods | Contract lives in |
|---|---|---|
transport::HttpTransport | send, send_streaming | rustdoc; non-2xx is not an error at this layer |
store::KvStore | get, put, put_if_absent, compare_and_swap, delete | meta_whatsapp_adapters::store::conformance (executable) |
store::ConversationStore | append, append_synced (a batch of coexistence history: no window, never unread), fill_media_placeholder, revoke (number and direction scoped, never the conversation; the content kept; a tombstone, history only, when the message is not stored yet), update_status (scoped: phone_number_id, id, status, at, error), messages, conversations, mark_read, last_inbound_at; message (the lookup, scoped: phone_number_id, id, never another number's); record_window_event, window_events (a call or a standby message that reopens the window: never history, never the summary); set_thread_owner, thread_owner (Conversation Routing: the latest record wins); put_contact, remove_contact, contact, contacts (the coexistence address book; a removal is kept, key and time only, so an older sync arriving after it is refused); link_identity, identity_links (two identities of one person on one number, as Meta reports a BSUID or number change); identities (a person's keys on one number: the closure over the synced contacts and the links, read only); erase_all (a person on one number, deleted: every record under their keys, the contacts and links naming them, and their messages in groups redacted or deleted per the store's ErasureMode); purge_before (by age, one number or all); trim_conversations (a cap on each conversation's history: its newest rows kept, every row counting, the summary deleted when its latest message goes; one number or all); erase (one key), erasure_mode, retention, apply_retention (provided: redact group messages and keep everything unless the adapter is configured) | meta_whatsapp_adapters::store::conversation_conformance (executable) |
sink::EventSink<E> | deliver | rustdoc |
clock::Clock | now | — |
media::MediaConverter | convert (a MediaInput into a TargetFormat: OggOpus, a voice message's mono Ogg Opus, uploaded as audio/ogg; StaticSticker, a static WebP sticker, image/webp; Jpeg and Png, 8-bit images, image/jpeg and image/png; TargetFormat::max_bytes is core's own media::AUDIO_MAX_BYTES, media::STATIC_STICKER_MAX_BYTES (100 000) and media::IMAGE_MAX_BYTES (5 000 000: Meta's figures read as decimal), kept within the client's upload table by every_target_format_is_what_the_upload_check_takes) | rustdoc; meta-whatsapp-client re-exports it from client::media (Media::upload_converted) |
Typed stores are built on KvStore, never as new ports: token vault, OTP challenges, webhook dedup, Embedded Signup sessions, bot cooldowns, the bot's durable jobs. An adapter author implements five methods once and every feature works.
What the inbox keeps about a conversation beside its messages (window events, thread ownership, the coexistence address book, the links between a person's identities) is on ConversationStore itself, not on KvStore: it is keyed like the history, and an erasure must reach it in the same step. Its personal data is the records keyed by a contact; a person also appears in group conversations (a message's sender: the BSUID, else the phone number, of an inbound message, a column on Postgres) and in other conversations' payloads (a quoted message, a shared contact card), which no erasure reaches. A person has several keys on one number (a history thread under their phone number, live messages under their BSUID, an earlier BSUID), so erasure is by person (design D30): identities(key) collects the keys the store connects, and erase_all(phone_number_id, keys) deletes, in one step, every record under them, the synced contacts and identity links naming them, and their group messages, redacted in place (the default) or deleted (ErasureMode, a setting per store, design D31); erase(key) is its one-key case. There is no erasure across numbers: a wa_id is the same on every number, so one would delete other businesses' customers. What it does not reach, and what an integrator does about it, is in cms-inbox.md § 8 and production.md § 8: quotes and contact cards in other people's messages, the ids of redacted group messages, identities nothing connects, the webhook dedup markers (kept on purpose: they stop Meta's redeliveries from recording the data again), the service's outbox and idempotency answers, the webhook dead letters (their own store, with their own index and erase: the facade's erasure::Eraser erases a person from both, roadmap L21a), integrators' copies, dead Postgres rows, WAL, replicas and backups, logs, and Meta's side; and records created again after the erasure. Retention is set per store (design D10): the adapters take a Retention (with_retention, keep by default) that apply_retention(now) applies; nothing purges on its own, and a caller with another policy calls purge_before with its own cutoff. A cap on each conversation's history is trim_conversations; the bot framework schedules both (meta_whatsapp_bot::retention, RetentionPolicy and RetentionSweeper, roadmap B4).
Stable identifiers
These never change, whatever the project is called: each is encrypted into, hashed into, or names data already stored (or, for a lock, is what replicas of two releases must agree on while a deploy rolls), so changing one strands that data (or makes migrate refuse the database) on the next upgrade. The library's predate the rename from wa-rs to meta-whatsapp-rs and keep the old name; the service's (meta-whatsapp-server) postdate it and carry the new one, and are just as fixed. A test pins each one's exact bytes; a change that fails it is a data migration, not a rename. The pins spell the w of their literals \x77, or pin opaque bytes (captured records, digests, known answers computed apart from the code), so that a search-and-replace cannot rewrite them along with the code.
| Identifier | Defined in | What it is | Pinned by |
|---|---|---|---|
wa-rs/token-vault/v1 | AAD_TAG, meta-whatsapp-client/src/embedded_signup/vault.rs | associated data of every sealed token | a_record_written_before_solution_partner_mode_still_opens (bytes written by b805dac) |
wa-rs/token-vault/ledger/v1 | LEDGER_AAD_TAG, same file | associated data of every credit ledger record | ledger_records_written_before_the_rename_still_open (bytes written by 16b61db) |
wa.token | TOKEN_NAMESPACE, same file | store namespace of tokens, the phone index and the credit ledger; also in their associated data | both tests above |
wa.otp.key, wa.otp.code | KEY_DOMAIN, CODE_DOMAIN, meta-whatsapp-client/src/authentication/otp.rs | HMAC domains of the challenge's store key and of the code hash | the_derivations_and_namespaces_are_pinned (known answers), the_key_derivation_is_pinned, the_code_hash_is_pinned_and_covers_the_key |
wa.otp, wa.otp.rate | NAMESPACE, ISSUE_LOG_NAMESPACE, same file | store namespaces of the challenges and of the issue limit | the_derivations_and_namespaces_are_pinned |
wa.es.session | SESSION_NAMESPACE, meta-whatsapp-client/src/embedded_signup/session.rs | store namespace of signup sessions in flight | the_session_namespace_is_pinned |
wa.es.coexistence (added by roadmap L11d; the namespace and the record layout pending the owner's confirmation, roadmap § Owner touchpoints) | COEXISTENCE_NAMESPACE, meta-whatsapp-client/src/embedded_signup/coexistence.rs | store namespace of the coexistence sync records (key: the phone number id; value: plain JSON, "v":1, the WABA id, the stored token record's created_at in Unix seconds, and per request, contacts and history, its state, pending, requested with Meta's request_id or refused with its code, and at). Not ephemeral: changing it forgets which requests an onboarding made, and Meta allows each once per number, so a resume would post again one that was accepted, or whose answer was lost | the_sync_record_is_pinned |
wa.webhook.dedup | DEDUP_NAMESPACE, meta-whatsapp-webhooks/src/dedup.rs | store namespace of the dedup markers (key: SHA-256 hex of the event's dedup key) | the_marker_key_is_pinned |
wa_ | TablePrefix::DEFAULT, meta-whatsapp-adapters/src/store/table_prefix.rs (in store/postgres/mod.rs until roadmap L8; the SQLite adapters share it) | default table prefix: wa_kv, wa_messages, wa_conversations, wa_window_events, wa_thread_owners, wa_synced_contacts, wa_identity_links, wa_sqlx_migrations | the_default_tables_and_migration_checksums_are_pinned, prefix_validation, the live_postgres_* table-name tests |
customer_call, call_accepted, standby_message; this_app, another_app, idle; revoked, erased | WindowEventKind::as_str, ThreadOwner (its serde names), StoredMessage::REVOKED, StoredMessage::ERASED, meta-whatsapp-core/src/store/conversation.rs | the names a conversation store keeps for a window event's kind, a thread's owner and this crate's own message kinds (wa_window_events.kind, wa_thread_owners.owner, wa_messages.kind_utf8) | stored_names_are_pinned |
the number lock ('wa_messages'::regclass::oid::int4, hashtext(<phone_number_id>)), the purge lock ('wa_conversations'::regclass::oid::int4, 0) | number_lock, purge_lock, meta-whatsapp-adapters/src/store/postgres/conversation.rs | Postgres advisory locks (the two-key form, the table's object id as class) that order erase, purge_before and trim_conversations (under the purge lock, like a purge) against appends and each other: replicas of two releases must take the same ones | the_locks_are_pinned, live_postgres_the_locks_are_the_documented_ones |
| the migration files | meta-whatsapp-adapters/migrations/*.sql | sqlx records each file's checksum; an edit, a comment included, makes migrate refuse every database migrated before. So they keep naming wa_adapters, including in the hint migration 3 raises, and the columns and indexes they create keep their names (migration 4's wa_messages.sender, wa_messages_sender_idx, wa_messages_unsent_idx, wa_identity_links_idx, among others) | the_default_tables_and_migration_checksums_are_pinned |
a message's sender: the payload's from_user_id, else its from, of an inbound message (an empty value, or one holding U+0000, is none) | StoredMessage::sender, meta-whatsapp-core/src/store/conversation.rs; migration 4's back-fill | what a store keeps beside each message (wa_messages.sender) and an erasure matches a person's group messages on: a changed rule leaves the rows written before out of every erasure | the_sender_is_the_bsuid_else_the_phone_number_of_an_inbound_message, live_postgres_migration_4_backfills_senders_by_the_rust_rule |
wa: | RedisKvStore::new, meta-whatsapp-adapters/src/store/redis_kv.rs | default Redis key prefix, before {<len>:<namespace>}:<key> | the_default_prefix_and_key_layout_are_pinned |
| the SQLite migration files and tables (added by roadmap L8; pending the owner's confirmation before the first release, roadmap § Owner touchpoints) | MIGRATIONS, meta-whatsapp-adapters/src/store/sqlite/mod.rs; meta-whatsapp-adapters/migrations-sqlite/*.sql | the SQLite adapters' schema under the default prefix: wa_kv, wa_kv_version (the version counter), wa_messages (keyed by business number and id; wa_messages_id_idx, unique, keeps an id once per store, OPEN_QUESTIONS.md #33), wa_conversations, wa_window_events, wa_thread_owners, wa_synced_contacts, wa_identity_links, wa_sqlx_migrations, their columns (kind_utf8, text_utf8, payload_json, ts in unix microseconds, …) and the stored names the Postgres adapter keeps (statuses, directions, owners, kinds); sqlx records each file's checksum, so an edit, a comment included, makes migrate refuse a database migrated before | the_default_tables_and_migration_checksums_are_pinned (store::sqlite) |
{conversations:<prefix>} and the Redis conversation layout (added by roadmap L8; pending the owner's confirmation before the first release, roadmap § Owner touchpoints) | RedisConversationStore::base, key, ts16, PRELUDE, meta-whatsapp-adapters/src/store/redis_conversation.rs | the hash tag after the wa: prefix of every key of a conversation store (naming the prefix: one slot per store), the keys under it (:m:, :id:, :h:, :s:, :l:, … with length-prefixed parts), the fields of each record (c, d, k, t, p, s, ts, …) and the time encoding (16 hex digits of the unix microseconds, sign bit flipped) | the_conversation_layout_is_pinned, each_prefix_has_its_own_hash_tag, live_redis_conversation_layout_and_erasure_leaves_nothing |
meta-whatsapp-server/outbox-key/v1 | outbox_key, meta-whatsapp-server-core/src/events.rs | domain of the SHA-256 stored as wa_server_events.dedup_key (with the tags library and delivery, the NUL separators and a keyless event's position as 8 big-endian bytes): derived otherwise, a redelivery across the upgrade is recorded twice | the_outbox_key_is_pinned (known answers) |
meta-whatsapp-server/event-id/v1, evt_ + 32 hex digits | EventIdKey, same file | HMAC label of the key event ids are derived with under the app secret, and the id's shape: stored as wa_server_events.id and what receivers deduplicate on | event_ids_are_pinned (known answers) |
meta-whatsapp-server/migrate, meta-whatsapp-server/housekeeping | MIGRATION_LOCK, HOUSEKEEPING_LOCK, lock_key, meta-whatsapp-server-store-postgres/src/postgres.rs (meta-whatsapp-server/src/store/postgres.rs until roadmap S7) | Postgres advisory lock keys (the first 8 bytes of their SHA-256; lock_key derives the leader lock's the same way, so its housekeeping turn is HOUSEKEEPING_LOCK): replicas of two releases take the same ones | the_lock_key_is_derived_as_documented, the_leader_locks_keys_are_the_services_locks |
the service's migration files, wa_server_sqlx_migrations | meta-whatsapp-server-store-postgres/migrations/*.sql (moved byte for byte from meta-whatsapp-server/migrations/ in roadmap S7: sqlx knows a migration by its version and checksum, never its path), MIGRATIONS_TABLE | the service's wa_server_* tables (the operator-only event stream '' included) and its migration history; an edited file makes migrate refuse every database migrated before | the_migrations_and_their_checksums_are_pinned, live_postgres_a_database_mains_binary_migrated_starts_this_one_with_no_migration |
wa_server_wabas.generation, wa_server_wabas.previous_tenant_unbound_at and its default 1970-01-01 00:00:00+00 (UNRECORDED_MOVE), wa_server_waba_tombstones (waba_id, tenant_id with ON DELETE SET NULL, unbound_at, previous_tenant_unbound_at), migration 4's description waba moves (added by roadmap S2b; the names, the default's meaning and the tombstone's rules pending the owner's confirmation, roadmap § Owner touchpoints) | meta-whatsapp-server-store-postgres/migrations/0004_waba_moves.sql, WABA_COLUMNS and unbind_sql, meta-whatsapp-server-store-postgres/src/postgres.rs; WabaBinding, WabaTombstone, UNRECORDED_MOVE, meta-whatsapp-server-core/src/model.rs | a binding's attach count (what a capability's conditioned writes compare beside attached_at) and when its WABA was last unbound from another tenant (NULL: never; the epoch: a binding made before migration 4, its history unknown), and each WABA's last unbinding, which a new binding reads: what decides whether an event is routed by the rules after a move (design D36). Renamed, the rows written before lose their history (every move forgotten, so the window after it fails open); the epoch default read otherwise would give main's bindings the first-onboarding rules | the_migrations_and_their_checksums_are_pinned (checksum and description), live_postgres_a_database_mains_binary_migrated_starts_this_one_with_no_migration (the default's semantics on main's rows), records::a_move_is_recorded, records::a_refresh_moves_the_generation (both backends) |
wak_ | PREFIX, KEY_ID_CHARS, SECRET_CHARS, meta-whatsapp-server-core/src/keys.rs | API keys' prefix and layout (wak_ + 17 + _ + 43 base62 characters), held by every integrator | the_key_layout_is_pinned (literals); a_minted_key_parses_and_matches_its_digest_only, malformed_keys_do_not_parse, base62_is_fixed_width_big_endian |
wa.bot.cooldown, wa.bot.cooldown.notice | COOLDOWN_NAMESPACE, COOLDOWN_NOTICE_NAMESPACE, meta-whatsapp-bot/src/guard.rs | store namespaces of the bot's command cooldowns and of their "please wait" markers (key: SHA-256 hex of the length-prefixed business number, command and user key; for a subcommand, the command part is its path, order status). Ephemeral: changing it forgets running cooldowns (they last one period at most) and may repeat one notice each; no data is stranded | the_cooldown_key_is_pinned_and_hashed, a_subcommands_cooldown_key_is_over_its_path |
wa.bot.pacer (added by roadmap B2b; the namespace and the record layout pending the owner's confirmation, roadmap § Owner touchpoints) | PACER_NAMESPACE, meta-whatsapp-bot/src/pacer/kv.rs | store namespace of KvRateLimiter's per-number schedules (key: SHA-256 hex of the length-prefixed phone number id, <len>:<id>,; value: the 49-byte version 1 record, its first byte the version). Ephemeral: changing it forgets each number's schedule, slow-down and raised rate (records expire 24 hours after their last slot by default), so while replicas of two releases run side by side they book from two budgets; no data is stranded | the_pacer_key_is_pinned_and_hashed (known answer), the_pacer_record_is_pinned |
wa.webhook.deadletter, wa.webhook.deadletter.contact (added by roadmap L21a; the namespaces, the keys' derivations and the records' shapes pending the owner's confirmation before the first release, roadmap § Owner touchpoints) | DEAD_LETTER_NAMESPACE, DEAD_LETTER_INDEX_NAMESPACE, slot_key, index_key, meta-whatsapp-webhooks/src/deadletter.rs | store namespaces of the webhook dead letters: the ring (slots 0…capacity - 1, decimal, each a letter at id % capacity, and its head under the key head, {"v":1,"next":<id>}, never expiring) and the per-contact index (key: SHA-256 hex of the length-prefixed business number, empty for an event that names none, and contact: <len>:<phone_number_id>,<len>:<contact>,; value {"v":1,"letters":[[<id>,<slot>],…]}); a letter is JSON with "v":1, in a pinned field order (id, at, attempts, kind, error, phone_number_id, contacts, event_bytes, event). Not ephemeral: changing a name, a derivation or a record's shape strands the letters written before, and an erasure no longer reaches them (they age out after 14 days by default) | the_keys_are_pinned_and_hashed (known answers), the_record_bytes_are_pinned (written and read back) |
wa.bot.jobs, wa.bot.jobs.due, wa.bot.jobs.sent, wa.bot.jobs.message, wa.bot.jobs.limited (pending the owner's confirmation before the first release, roadmap § Owner touchpoints; added by B3) | JOBS_NAMESPACE, JOBS_DUE_NAMESPACE, JOBS_SENT_NAMESPACE, JOBS_MESSAGE_NAMESPACE, JOBS_LIMITED_NAMESPACE, BUCKET_SECONDS, meta-whatsapp-bot/src/jobs/mod.rs; the records, meta-whatsapp-bot/src/jobs/store.rs | store namespaces of the durable jobs: the job records (key: SHA-256 hex of the length-prefixed job id), the due index (pages <minute start, unix seconds>:<page> of job ids, and its cursor, with the page versions of the minute behind it once moved forward), the per-recipient markers (digest of the job id, the run and the recipient's key: user:<BSUID> first, else phone:<digits>, or group:<id>), the sent messages' ids (digest of the wamid) and the marketing-limited markers (digest of the job id and the recipient's key); every record JSON with "v":1, in a pinned field order. Not ephemeral: changing a name, a key's derivation or a record's shape strands the jobs scheduled, or makes a resumed job send again to recipients it sent to | the_job_keys_are_pinned_and_hashed, a_recipient_is_keyed_by_bsuid_first, buckets_are_minutes_in_unix_seconds (key bytes, known answers); the_job_records_are_pinned, the_index_and_marker_records_are_pinned (record bytes, written and read back) |
Library store namespaces are wa.<module>[.<purpose>] (wa.token, wa.otp.rate, wa.webhook.dedup, wa.bot.cooldown): a new typed store takes one under its module, and an integrator who keeps their own records in the same KvStore stays clear of wa.. A row marked pending was added after the table was last confirmed: the owner confirms it before the first release, and until then it may still change without a data migration.
Not ours to rename either: Meta's names (wa_id, wamid, waba_id, wa.me, WA_EMBEDDED_SIGNUP, …), and the WA_ environment variables of the examples and the dev container (WA_TENANTS, WA_OTP_NAMESPACE, WA_FIREWALL_*, …), which are about WhatsApp, not about this project.
Error tree
meta_whatsapp_core::Error is the root; every public fallible function returns meta_whatsapp_core::Result<T>. See crates/meta-whatsapp-core/src/error/mod.rs for the tree.
- Every public fallible function returns
meta_whatsapp_core::Result<T>, with one exception: pure, I/O-free functions (signature/token verification, Flows endpoint crypto, Typst rendering, and every publicvalidate()) may return their precise error (CryptoError,WebhookError,meta_whatsapp_typst::RenderError,Result<(), ValidationError>);?lifts them intometa_whatsapp_core::Error. thiserrorfor every typed node.anyhow::Erroronly as the opaque leaf for failures raised by code we do not own (adapters, integrators):TransportError::{Connect, Backend},StorageError::Backend,SinkError::Delivery,Error::Other.- Branch on
Error::kind()→ErrorKind(classified from Graph errorcode, per Meta's guidance) andError::is_retryable(). Never on message text, HTTP status, or subcode. AValidationErrorisInvalidParameter(fix the input), except the inbox's two local refusals of a state that must change first, each with a field constant, a constructor, a predicate and a kind of its own: the closed window (ValidationError::customer_service_window_closed()) isCustomerServiceWindowClosed, like Meta's131047, so one condition has one kind; a thread another app owns under Conversation Routing (ValidationError::thread_owned_elsewhere()) isThreadOwnedElsewhere, like Meta's131070(a service message without owning the thread,conversation-routing/thread-control§ Errors).Error::kindlooks throughStep, and so doesmeta_whatsapp_rs::inbox::is_thread_owned_elsewhere. The service answers both409. - Two questions about a failed send, two methods.
Error::may_have_been_sentsays whether it may have reached Meta (false: fix and resend;true: reconcile with the status webhooks first).Error::may_resendis the one rule for resending it automatically, with no person or reconciliation in between, and it is conservative: only an error that proves Meta refused the request before doing anything (a throttling code on any status, the Business Use Case limits80008,80009and80014excepted, an HTTP 429,131057on a 4xx). The client'sRetryPolicy(for a request that is not idempotent) and the bot's broadcast both follow it, and a policy of theirs can only be stricter. A131000on a 400 is not resent automatically, althoughmay_have_been_sentisfalse. - Multi-step flows (Embedded Signup onboarding) wrap failures with
Error::in_step("stable_step_name")so callers know how far they got. Error::Credit(CreditError), the first leaf added for a feature module, and why: a Solution Partner credit line is money (the partner pays for every message on a shared line, and an attached line cannot be taken back), and its steps stop in ways no existing leaf describes. AValidationErroris never retryable and never sent, but a busy credit step is worth retrying, a share that raced a revocation did reach Meta, and a revocation that stopped part-way has already sentDELETEs and carries a report the caller must act on.CreditErrordecidesis_retryableandmay_have_been_sentper variant:Busy(retryable;postedonly for the two-call method's recorded share),Revoked(postedwhen a share raced a revocation and was revoked at once, or is recorded for the next revocation),OwnerUnknown,StatusUnknownandApprovalRequired(nothing sent, not retryable),Reconcile(sent, not retryable: a share may be live, its answer lost or not found by a racing revocation; a person checks Meta Business Suite),AttachFailed(the two-call method's share went out, its attach did not; retryable and kind as the attach's error) andRevocationIncomplete, whoseRevocationIncompletestruct holds theCreditRevocationreport, thefailed,unconfirmedandunattributedrecords,share_pending(a share with no recorded outcome the call revoked nothing for),deletes_sent, a ledger failure asledgerand the first failure on Meta's side as itssource; it is retryable unless a record names no business or the source is not (a ledger failure never makes it unretryable: a repeat writes the ledger again).CreditError::kind()isServiceUnavailableforBusyand for an incomplete revocation that only a later call can finish,Unknownfor the states only a person can settle (Reconcile, records naming no business), the source's kind for an incomplete revocation with one, andInvalidParameterfor the refusals. Helpers:Error::credit()(looks throughStep, likegraph()),CreditError::revocation(), andEmbeddedSignup::is_credit_line_revoked/is_credit_step_busy.SinkError::Permanent(anyhow::Error), the one variant added to an existing leaf for dead-lettering (roadmap L21a,OPEN_QUESTIONS.md#30), and why: only the sink knows whether an event can ever be accepted (a value its database refuses forever, a shape its code cannot handle), and the webhook handler must tell that from "try again later" without knowing the sink's error types.SinkError::permanent(e)builds it,SinkError::is_permanentasks; every other variant is transient, so a sink that never classifies keeps today's behaviour (500, Meta redelivers the batch). Not anErrorKind(those classify Graph codes) and not a method onError(the handler sees theSinkErrorbefore it becomes one).is_retryableandmay_have_been_sentarefalse, like every sink error.FanoutSinkreturns a transient error before a permanent one, so a fan-out is permanent only when every failing sink was.Error::Conversion(ConversionError), the second leaf added for a feature, and why: amedia::MediaConverter(roadmap L19) fails in ways that are not input errors alone. A converter that is not installed (BinaryMissing) or lacks an encoder (EncoderMissing) is the deployment's to fix (FeatureNotAvailable); a run past its time limit (Timeout,ServiceUnavailable) is the one worth retrying; an input it cannot read (InputRejected) isInvalidParameter; an output of the wrong format (OutputInvalid) or a process killed (Killed) isUnknown. AValidationErrorwould make the first two the caller's fault and the timeout final; aTransportError::Timeoutwould say a conversion may have reached Meta. None of them was sent:may_have_been_sentisfalsefor every variant. An empty input, or one too large before or after conversion, stays aValidationErroroninput. A missing binary is this node, not aConfigError, so that a caller handles every converter failure in one place. It adds noErrorKind, so the service's error codes are unchanged (itsclassifyanswers itinternaluntil a route converts, M5c1). Helper:Error::conversion()(looks throughStep).- Examples and binaries use
anyhow::Resultat the edge. - No module adds a variant to the root for its own convenience; a new leaf needs a reason in this document.
Client (meta-whatsapp-client)
Client = Arc<Shared{transport, endpoint, retry, timeout, user_agent}> + optional AccessToken. client.with_token(t) is the multi-tenant switch.
Endpoint modules follow one pattern (src/messages/mod.rs, abridged):
impl Client { pub fn messages(&self, id: impl Into<PhoneNumberId>) -> Messages }
impl Messages {
pub async fn send(&self, message: &OutboundMessage) -> Result<SendResponse> {
message.validate()?; // documented limits, before any request (rule 3)
self.client.post_at(&[self.phone_number_id.as_str(), "messages"])
.json(message)
.context(SEND_CONTEXT)
.send_private() // the response names the recipient: see rule 8
.await
}
}Rules for every endpoint module:
- Build requests only through
GraphRequest(client.get_at/post_at/ delete_at(&[segments])for any path with an id;client.get/post/deletefor literal paths only). It owns auth, retries, error decoding, the credential host allowlist, and segment-safe paths (an id containing/,?or#stays inside its segment; empty,.and..are refused). Ids have one of two shapes (meta_whatsapp_core::ids, roadmap L20c). The ids Meta documents as numeric strings (AppId,BusinessId,WabaId,PhoneNumberId,MediaId,TemplateId,ProductItemId,ProductSetId) refuse anything but 1 to 64 ASCII digits when built (new,FromStr,TryFrom: aValidationErroron the type'sFIELD), so a numeric id cannot carry a/into a path. Every other id is opaque (any string: BSUIDs, group ids, message ids, …) and relies on the whole-segment encoding alone: Meta documents no shape that would not refuse a legitimate one (a standard-base64 group id may hold/). Some opaque ids are digits in Meta's examples (Flow, catalog and template group ids, credit lines, allocation configurations, verification submissions, bot ids), but no page states their shape, so they stay opaque until one does (the follow-up in roadmap L20c). Deserializing never checks, so a response or webhook never fails on an id's shape;new_uncheckeddoes the same for Meta's values that arrive as plain strings and for rows read back from a store, and is never for untrusted input (a form, a URL, a request body: usenew). What stays unverified: whether Graph decodes%2Fbefore routing. If it does, a caller who controls an opaque id (or one built unchecked) could reach another edge of an object the token can access; the endpoint modules' own checks (digits where a page asks for them) narrow that, and refusing/in opaque ids outright is the swap, at the cost of a legitimate id. - Requests are typed structs with
Serialize, responses typed withDeserialize. Unknown response fields are ignored (neverdeny_unknown_fields); enums Meta may extend get a catch-all variant so a new value never breaks parsing. Which name and shape all of them share, and the#[non_exhaustive]policy, were decided on 2026-09-26 (OPEN_QUESTIONS.md#27, #28) and land with roadmap L20a and L20b. Until then, follow the module you are in, and add no new unit#[serde(other)] Unknownvariants: they drop Meta's value, and a type that also derivesSerializewrites its own name (e.g."UNKNOWN") back instead. - Validate locally what Meta documents as a hard limit (lengths, counts, formats) and return
ValidationErrornaming the field. Do not invent limits the docs do not state. - Idempotency: POSTs are non-idempotent by default. Mark a POST
.idempotent(true)only when replaying it cannot duplicate an effect (e.g. setting a field to a value). - Lists return
Page<T>and take their cursors in their query struct (after,before: the next page is the same query withafter = page.next_cursor()); each offers a…_stream()viaGraphRequest::paginate, which manages the cursors and refuses a query that already holds one (aValidationError, the stream's single item). A list whose page documents no pagination (waba.subscribed_apps,templates.library) takes no cursor and says so. The exception: a list whose page documents no pagination and nopagingobject in its response may return aVec<T>instead (authentication::previews,Business::messaging_customer_bases,PhoneNumber::username_suggestions,PhoneNumber::business_compliance_info); a new one says so in its rustdoc. If Meta documents paging for one later, it moves toPage<T>(a breaking change). - Tests use
meta_whatsapp_core::testing::ScriptedTransport: assert method, path, query, auth header and exact JSON body; feed responses copied from the docs' examples. Every test that scripts N responses assertsremaining() == 0. - Docs: every public item has rustdoc; each module doc names the Meta doc paths it implements (relative to
https://developers.facebook.com/documentation/business-messaging/whatsapp/). - Responses that name people (the send responses of
Messages::sendandMarketing::sendecho the recipient's number) decode withGraphRequest::send_private: a decode error carries a placeholder instead of the body snippet, and the error category and position instead of serde's message (which quotes the offending value). Paged answers are withheld the same way without the call site asking: Meta'spaging.nextandpaging.previouslinks can embed theaccess_tokenthe request was sent with, soGraphRequest::send(and every other decode of a 2xx body, throughrequest::decode_json) withholds the snippet and serde's message of any body that names apagingkey oraccess_token. The rule reads the body, not the target type: it holds forPage<T>, for a module's own struct with apagingfield, and for a page nested in an object, and for a stream's items. AnError::Httpbuilt from a body (a"success": falseanswer, a non-Graph error body) takes its snippet fromrequest::safe_snippet, under the same rule. Other answers keep their snippet and serde's message. A module that decodes asend_raw()body itself callsdecode_jsonrather than passing the body toError::decode, and buildsbody_snippetwithsafe_snippet;request::body_guard_testsscans the crate's sources (outside comments, literals and test code) for any other way, each exception pinned to its function. A decodedPagingkeeps no credentials: itsnextandpreviouslinks lose theiraccess_tokenandappsecret_proofparameters, andHttpResponse'sDebugprints the body's length only. A response that carries another secret decodes withsend_withheld(placeholder)(Flow::preview: its link'stokenopens the Flow). - One type per concept. A type two endpoint families share is defined once in
meta_whatsapp_client::common(MediaSource,FlowAction,QualityRating) and re-exported by each module that uses it; a type of one module never shares its name with a type of another (flows::endpoint::EndpointActionis the endpoint request'saction,common::FlowActiontheflow_actiona Flow starts with). New code types every Graph id with ameta_whatsapp_core::idsnewtype. Known exceptions, stillString(typing them is a breaking change each): the groups'request_idandjoin_request_id;MessagingCustomerBase::id,CreatedMessagingCustomerBase::messaging_customer_base_idand the signups'default_messaging_customer_base_id;OnboardingRequested::request_id; the calling settings'app_id;SubscribedAppData::idandAssignedUser::id;CommerceSettings::id;LibraryTemplate::id; the template'sad_*_ids;preverified_id; the phone numberrequest_id; the ids of an Embedded Signup session event (ad_account_ids,page_ids,dataset_ids,catalog_ids,instagram_account_ids,session_id), the launch'ssolution_idand the token'suser_id; andHealthEntity::id, on purpose: a health status entity's id is a phone number, template, WABA, business or app id depending on itsentity_type, so no one newtype fits it. Merchant-chosen ids (product retailer ids, button ids) and our own (an OTP challenge id) are strings on purpose. - List queries are named
List*(ListQrCodes,ListSignups,ListFlows,ListFlowAssets,ListAssignedUsers,ListClientWabas,ListGroups,ListBlockedUsers, …) and carryafter/before; the one-page method sends them, the…_stream(&query)refuses them. The older*Querynames stay as they are (renaming them would break integrators for no gain):PhoneNumbersQuery,WabaListQuery,TemplateListQuery,LibraryQuery,TemplateAnalyticsQuery,TemplateGroupAnalyticsQuery,GroupAnalyticsQuery.
Retries
RetryPolicy (src/retry.rs): idempotent requests retry on any retryable error; non-idempotent requests only when the error proves Meta rejected the request before processing (throttling). A timeout on a send is never replayed — a duplicate OTP or order confirmation is worse than an error.
Credentials never leave Meta
GraphRequest attaches a token to the configured Graph endpoint (same scheme, host and port; https://graph.facebook.com unless ClientBuilder::endpoint names a proxy, in which case production Graph is just another host) and to what its credential rules allow. The rules are data (CREDENTIAL_RULES, next to GraphRequest), one line per documented need; every line is https on the default port and an exact host, and adds a method and a path rule:
| Rule | Why |
|---|---|
https://lookaside.fbsbx.com, any method, any path | media download URLs point there, and Meta refuses a download without the token |
GET https://api.facebook.com/{digits}/parent-bsuid-accounts, nothing else on that host | the Parent BSUID Accounts API (Business::parent_bsuid_account) is served there, not on Graph, and takes no API version (business-scoped-user-ids, § Get parent BSUID account; the page's changelog entry of May 28, 2026 corrected its host from graph.facebook.com) |
The second line's path is matched as it goes on the wire, after URL parsing (percent-encoding kept, so %2F is not a separator and %31 not a digit; . and .. resolved): exactly two segments, the first ASCII digits, the second the literal edge, and no query, fragment or user info. A version segment, a trailing segment or slash, another method, http, another port, or any other path on api.facebook.com is refused. Neither rule follows the configured endpoint: behind a proxy the token goes to the proxy and to these two, never to graph.facebook.com (an egress proxy that must see every request belongs in the HttpTransport). A new line widens where credentials go, so it goes through the security review.
Any other URL with a token (another Meta host such as *.whatsapp.net or *.fbcdn.net, a subdomain, a look-alike — the host is compared exactly after URL parsing, so suffixes, trailing dots and IDN homographs do not match —, another port, plain HTTP) fails with ValidationError on url before the transport sees it; the error names the host, the method and the rules, never the path, the query or the token. Inside the library, the only requests to an absolute URL are Media::download_with_info and Business::parent_bsuid_account; integrators reach the same check through Client::request_url, and cannot step around it with a header: GraphRequest::header refuses Authorization, Proxy-Authorization and Cookie (any case, with or without no_auth(); a validation error on the name before any request), and a URL with a user name or password is refused on send (reqwest would send them as Authorization: Basic, to any host), so bearer() and oauth(), which the check covers, are the only ways to attach an Authorization header. A secret an integrator puts in a query parameter or in another header goes where that URL goes: the check does not read them. Pagination re-issues the original request with after= rather than following paging.next.
The check covers the URL the client hands to the transport, so a redirect must not carry the token further: that is the HttpTransport port's rule (its rustdoc). reqwest rebuilds every hop from the request's original headers and drops the credentials only on a hop that changes scheme, host or port from the URL that answered it, so it would send the token to another path on api.facebook.com, past the second rule, and, after a hop to another origin, to any URL within that origin. The stock ReqwestTransport therefore sends a request that carries a credential (a credential header, or user info in its URL, which reqwest turns into one) through a client that follows no hop keeping scheme, host and port (the 3xx is the response, an Error::Http in the client), and follows a hop to another origin without it, up to 10. That policy is public, meta_whatsapp_adapters::http::credential_redirect_policy(). A request without credentials follows reqwest's default. Integrators who build their own reqwest::Clients pass two to ReqwestTransport::with_clients, the credentialed one built with that policy (or with no redirects). The single client given to ReqwestTransport::with_client sends every request, so its policy is the credentialed requests' too: the transport cannot change a built client's policy, nor stop reqwest following a redirect inside execute, so that client must be built with the policy or with no redirects (its rustdoc says so, and points to with_clients).
Decided 2026-09-27 (coordinator, owner's delegation), on the choices roadmap L10a's draft proposed:
- The rules do not follow a configured Graph endpoint or proxy: behind one, the token goes to the proxy, the media host and the one Parent BSUID Accounts URL. A Graph proxy stands in for Graph, which serves neither. Swappable by setting the proxy on the transport (
HTTPS_PROXY, or proxied clients inReqwestTransport::with_clients, or anotherHttpTransport), which then sees every request. Roadmap L10a. - The business id in the rule and in
Business::parent_bsuid_accountis ASCII digits: fail closed, since Meta documents no other shape and every id its pages show is digits. Swappable only by a reviewed change to the rule's line. Roadmap L10a. - An exact rule allows no query, fragment or user info: the one URL it exists for has none. Swappable only by a reviewed change to
PathRule::Exact. Roadmap L10a. ParentBsuidAccount::parent_bsuid_account_idis aString: core has no id type for it. Swappable by a typed id in core, a change of the field's type. Roadmap L10a.parent_bsuid_account_idis required andenrolled_business_portfoliosdefaults to empty: what Meta answers for a portfolio that is not enrolled is not documented, so an answer without the id is a decode error rather than a guess. Swappable by reading the same URL as JSON (client.request_url(Method::GET, url).send::<serde_json::Value>(), which the rule allows). Roadmap L10a.- The media host's rule is unchanged (any method, any path; pinned by
the_media_rule_is_unchanged): narrowing it toGETis a separate change. No code.
Feature modules
Messages (meta_whatsapp_client::messages)
OutboundMessage { recipient: Recipient (flattened), context?, biz_opaque_callback_data?, category? (Direct Send), ttl_seconds? (Direct Send), direct_send_config? (Direct Send), content: MessageContent } where MessageContent is an internally tagged enum on type: text, image, audio, video, document, sticker, location, contacts, interactive (button, list, cta_url, location_request_message, flow, product, product_list, catalog_message, carousel, voice_call, call_permission_request, request_contact_info, address_message), reaction, pin/unpin, template, and a Raw escape hatch that refuses envelope keys. Constructors cover the common cases (OutboundMessage::text(to, body), …). Every documented limit is checked locally before sending; templates run TemplateMessage::validate. mark_read(message_id) (idempotent) and mark_read_with_typing_indicator(message_id) (not replayed: a late retry could show "typing…" after the reply). SendResponse { messaging_product, contacts: [{input, wa_id?, user_id?, parent_user_id?}], messages: [{id, group_id?, message_status?}] } — shared with the MM API.
Recipient addressing follows the BSUID rules in meta_whatsapp_core::recipient.
Media (meta_whatsapp_client::media)
Upload (multipart: file, type, messaging_product; MIME type and size checked first), url(media_id) → {url, mime_type, sha256, file_size, id}, download(media_id) → streaming body with a verified() adaptor that hashes while streaming (a mismatch is TransportError::Integrity, retryable), download_bytes with a size cap (pre-allocation never trusts Meta's reported size beyond 16 MiB), delete. A download that answers non-2xx is read to at most 64 KiB (request::STREAMED_ERROR_BODY_CAP), then the stream is dropped and what was read decoded as any error body (GraphRequest::send_streaming); the 2xx body is the caller's to bound. Resumable Upload API (/{app_id}/uploads → upload:<id> sessions, file_offset, Authorization: OAuth on every step so the token never sits in a URL) returning a meta_whatsapp_core::ids::UploadHandle for template header_handles.
Templates (meta_whatsapp_client::templates)
CRUD + library + migrate + compare. Two builder families that must not be confused:
- Definition (creating/editing a template):
TemplateDefinition { name, language, category, parameter_format, components: [Header|Body|Footer|Buttons|Carousel|LimitedTimeOffer…] }with examples. - Invocation (sending):
TemplateMessage { name, language, components: [header params, body params, button params by index/sub_type] }, embedded inMessageContent::Template.
Named and positional parameters are both supported (parameter_format).
Authentication (meta_whatsapp_client::authentication)
Authentication template definitions (copy code, one-tap with supported_apps package/signature hash, zero-tap with zero_tap_terms_accepted), add_security_recommendation, code_expiration_minutes, previews, bulk upsert.
OtpService on KvStore + Clock:
- Recipients are strict E.164 with
+. Meta prepends the sending number's country code to a number without+, so a digits-only key would let12015553931(delivered to+91 12015553931) verify+12015553931— an account takeover. The code is sent to exactly+<digits>and keyed by those digits. BSUID-only recipients are refused locally (OTP buttons need a phone number; Meta's131062). - Challenges are scoped to the service: the store key is HMAC(pepper,
"wa.otp.key" | scope | digits | purpose), where the scope is the sendingphone_number_idand the requiredOtpConfig::namespace(the tenant;OtpConfig::new(namespace), noDefault), netstring-encoded so neither can be shifted into the other. Services sharing a store and a pepper (several merchants of one integrator) never see each other's codes, cooldowns or issue limits, on their own numbers or on a shared one. A blank namespace (or one with edge whitespace, control characters or format characters,Cf) is a config error; changing the scope (or upgrading across the commit that introduced it) invalidates outstanding codes. Making the namespace required kept the encoding: a service that had set one derives the same keys. issue(recipient, purpose) → IssueOutcome { Sent(Challenge{id, expires_at, message_id}), CoolingDown{retry_after}, RateLimited{retry_after} }: CSPRNG numeric code (length 4–8), only an HMAC-SHA256 of it stored under a server pepper (SecretBytes): HMAC(pepper,"wa.otp.code" | key | challenge id | code), so a record copied to another key (by anyone who can write the store but lacks the pepper) never verifies there. Keys are HMACs too (no raw phone number in the store); the namespace and the purpose are server-side constants, never request input. The code goes out throughMessages::send(OutboundMessage::template), so the send checks and the private response decoding apply. Resend cooldown (30 s) and a per-recipient issue limit (default 5 per sliding hour per number and purpose, per service scope; opting out is explicit) bound brute force to ~0.06 %/day for 6 digits. A challenge is removed only when the send was provably rejected (4xx, throttling); after a timeout or 5xx it stays, because the code may have been delivered. The line isError::may_have_been_sent, the one integrators use for their own sends.verify(recipient, purpose, code) → VerifyOutcome { Verified, Invalid { attempts_left }, Expired, TooManyAttempts, NotFound }: constant-time compare; attempts counted withcompare_and_swapbefore comparing, so concurrent guesses cannot exceed the limit; a verified challenge is consumed (single use).- The code never appears in logs, errors, or
Debugoutput.
Embedded Signup (meta_whatsapp_client::embedded_signup)
The most important flow. Frontend (Facebook JS SDK) runs FB.login with the app's configuration id; the page receives a WA_EMBEDDED_SIGNUP message event (FINISH with waba_id, phone_number_id, business_id; CANCEL with current_step; ERROR) and a short-lived code. The backend:
exchange_code(code)→ business integration system user token (GET /oauth/access_token?client_id&client_secret&code, no bearer).- Optionally
debug_token(app token) to confirm granular scopes and the WABA ids the token can reach — used when the session info is missing. waba(waba_id).subscribe_app(Option<&CallbackOverride>)(POST /{waba}/subscribed_apps) so webhooks flow;Somesends this WABA's webhooks to another callback.phone_number(id).register(&TwoStepPin, Option<&DataLocalizationRegion>)for Cloud API numbers (two-step PIN; optional local storage).- Persist the token in
TokenVault(onKvStore, encrypted at rest with AES-256-GCM under an integrator-supplied key, key id recorded for rotation) keyed by WABA id, with a phone-number → WABA index.
EmbeddedSignup::onboard(&request, &vault) → Onboarded (Tech Provider; Solution Partner mode requires onboard_with_approval) runs, in order: exchange code → debug_token → verify that the WABA id from the browser event is among the token's grants and that the phone number belongs to that WABA (browser-supplied ids are never trusted — otherwise one merchant could overwrite another's vault entry) → [onboard_with_approval only: the integrator's approval of the verified WABA, owner and numbers, whose refusal stops the flow before anything is written] → store the token → subscribe app → [Solution Partner mode: add the partner's system user to the WABA → share the credit line] → register number. The token is stored before the fallible later steps because the code is single-use and short-lived: storing last would lose the token whenever a later step fails (e.g. wrong PIN, 133005). verify_assets also reads the WABA's owning business from Meta (owner_business_info); a business_id claimed by the browser is ignored (it is what credit-line sharing keys on). Every number Meta lists on the WABA is stored in the phone → WABA index, so onboarding a second number never unroutes the first. resume(&waba_id, &request, &vault) loads the stored token and reruns the steps after store_token, refusing a session that names another WABA or an unverified number; request.code is not used, so after a restart a caller builds the request with no code, OnboardingRequest::for_resume(saved_session) (onboard refuses a code-less request before any request). SignupSessions::redeem(state, tenant) checks the tenant inside the library, is single-use, and does not consume the state on a tenant mismatch. Hosted Embedded Signup (onboard_hosted(&HostedSignup, &system_token, &request, &vault), from the PARTNER_ADDED account_update webhook's WABA and owner business) runs "fetch_token" (POST /{business}/system_user_access_tokens with the system token's appsecret_proof in a form body) in place of exchange_code, then the same steps, writing the same vault record; the WABA's owner as Meta reports it must be the webhook's, and a WABA the vault already holds is refused before any request (PARTNER_ADDED also comes for the code flow's onboardings). Each failure is Error::in_step("exchange_code" | "debug_token" | "verify_assets" | "approve" | "store_token" | "subscribe_app" | "assign_system_user" | "share_credit_line" | "register_phone"); offboard adds "revoke_credit_line" | "delete_token". Retrying the whole onboard call is not safe (the code is spent); retry with resume(). LaunchOptions builds the JSON for FB.login extras (version, featureType — incl. coexistence whatsapp_business_app_onboarding, setup pre-fill). SessionInfo parses the message event. SignupSessions (on KvStore) binds an opaque state id to the merchant that started the flow, so a callback can't be attributed to another tenant.
Tech Provider or Solution Partner, per deployment (owner's decision, 2026-09-24). Without EmbeddedSignup::solution_partner(SolutionPartner), onboarding is the Tech Provider flow above, request for request. With it (SolutionPartner { system_token (private), system_user_id, credit_line_id, method, default_currency, system_user_tasks }), following Meta's Solution Partner order (subscribe → share the credit line → register). The partner is liable to Meta for every message on a shared line and an attached line cannot be taken back, so the design is fail-closed:
- The WABA currency (
WabaCurrency: AUD EUR GBP IDR INR USD,Otheronly on purpose) comes fromOnboardingRequest::currency, else the default; missing, it is aValidationErrorbefore the code is exchanged. A Tech Provider request naming one (or a re-share) is refused the same way. The first currency is sealed before the first share is posted; another one is refused later. - Approval required before sharing (the owner confirmed it on 2026-09-25): plain
onboardis refused (CreditError::ApprovalRequired, before the code is exchanged); tenant checks on the verified ids go inonboard_with_approval, which runs beforestore_tokenand records the approval in the credit ledger (approved_at), bound to the token record it stores (approved_token_created_at).resumeshares only for a WABA whose stored token record was approved so;resume_with_approvalapproves a token stored without one (Tech Provider mode before a switch, or stored again since). The policy stays the integrator's (OPEN_QUESTIONS.md#6). CreditSharing::ShareAndAttach(default, Meta's current method):assign_system_user(POST /{waba}/assigned_users, system user token, the method's documented prerequisite), thenwhatsapp_credit_sharing_and_attach(system user token).CreditSharing::ShareThenAttach(Meta's alternate method):whatsapp_credit_sharingwith the verified owner business (system user token), thenwhatsapp_credit_attachwith the merchant's business token.share_credit_lineholds a per-WABA lease (put_if_absentwith a 300 s expiry, renewed by compare-and-swap right before each POST; lost, the step posts nothing more; the request timeout must stay well below it, and a post that outlives it anyway, whose answer is lost or not recorded, sets the pending-share flag again whatever cleared it meanwhile) and checks before it posts, inonboardandresumealike: the line's records for the owner business (owning_credit_allocation_configs, only records naming that business) plus the recorded allocation, each with itsrequest_status(the lookup does not say whether a record was revoked; no status is active, an undocumented one isStatusUnknown); an active one whose receiving credential is the WABA'sprimary_funding_idmeans nothing is posted. A POST whose answer is lost (a timeout, a 5xx) may have succeeded and Meta refuses a second attach, so it isReconcile(not retryable), and a share is never posted again without that check: the ledger sealspending_sharebefore each POST and clears it once the allocation is recorded (merged by compare-and-swap, never reported busy after a POST) or Meta provably did nothing, and a pending share nothing explains on a funded WABA isReconcile. When nothing funds the WABA the share is posted again, which assumes Meta's lookup andprimary_funding_idshow an applied share at once (undocumented). A refused attach after the two-call method's share isAttachFailedand keeps the flag. No owner business, no share (OwnerUnknown). A business marked revoked, or with onlyDELETEDrecords, is refused unlessOnboardingRequest::reshare_after_revocationopts in.- A revocation racing a share: the revocation writes its marker before it looks anything up; the share reads the marker after its POST, whatever the POST's outcome (a lost answer included). One of them sees the other: a share that finds a new or changed marker revokes by business what it may have made (the allocation it got back or recorded; with a lost answer, every active record naming the business, of which only one revoked now can be that share):
Revokedwithpostedonce that is revoked (the flag settled), elseReconcilewith the share kept pending. The revocation, meanwhile, re-reads the ledger after marking: a pending share of which it revoked nothing makes itRevocationIncompletewithshare_pending(retryable), neverOk, and one it revoked a record for is settled. A marker plus a pending share makes a later onboardingReconcile, notRevoked. An opted-in re-share clears the marker only by compare-and-swap on the version it read; a marker another opted-in re-share deleted counts as none (its opt-in funds the business again anyway), so a revocation between that re-share's read and its clear, whose lookup missed a third share, goes unseen by that third share. - A pending share Meta never lists (a post that never reached it) would keep every revocation incomplete for good, so an operator clears it (the owner's decision, 2026-09-25):
clear_pending_share(&waba_id, cleared_by, acknowledged_funding, &vault), after checking Meta Business Suite. It posts nothing and holds the WABA's credit lease (renewed before any ledger write; the clear is compare-and-swapped on the version it read). It checks Meta first, as a share does: the line's records for the owner business and the recorded allocation, with theirrequest_status, and the WABA'sprimary_funding_id(merchant token). An active record, one of undocumented status, or one the lookup returned naming no business clears nothing (PendingShareClearance::NotCleared; a record funding the WABA is recorded as its allocation). Nor does aprimary_funding_idthat no record explains, unlessacknowledged_fundingis exactly that id: it may be the lost share itself, applied before Meta's lookup lists it (the state a share callsReconcile), and only a person looking at Meta Business Suite can tell it from the merchant's own card. Otherwise the flag is cleared and aClearedShare(who, when, the pending share's time, the acknowledged funding) is appended to the record's audit trail. Revocation andoffboardthen behave as if nothing had been posted. The library imposes no minimum age on the pending share (Meta documents no delay; the owner rejected an automatic settle time): the operator waits. - The credit ledger (
TokenVault::credit,revoked_business):credit/<WABA>holds the owner, the allocation, the currency, the approval, the pending-share flag and the operator clearances (cleared_shares), written by compare-and-swap, with fields a later revision adds (to the record or to an audit entry) kept as read so a rollback does not drop them (a revision older than the trail drops the trail itself; integrators keep their own append-only log);revoked/<BUSINESS>marks a revoked business; both sealed with the vault keys (AAD bound to the store key), re-sealed on read like tokens, and left byTokenVault::delete, so revocation works after the token is gone (rotatecovers them,rotate_businessa marker no credit record names). Sealing stops forgery and moving; it does not stop someone with write access to the store from deleting a record or putting back an older copy (rollback is out of scope). The token record keeps its pre-Solution-Partner format. revoke_credit_line(&waba_id, owner_business_id, &vault)resolves the business (recorded at onboarding, else named by Meta's record of the recorded allocation and written back, else a signed webhook'sowner_business_idif the line has records naming it; a contradicting one revokes nothing; one whose check failed is marked once the revocation's own lookup finds records naming it), marks it revoked (replacing an unreadable marker; a failed marker write does not stop theDELETEs), then revokes every active record naming it plus the recorded allocation, confirming each; every record is attempted, and what is left undone isRevocationIncompletewith the report.revoke_business_credit_linedoes the same from a business id alone. Nothing calls either for the integrator: the owner's decision (2026-09-25) is that itsaccount_updatehandler revokes at once on everyPARTNER_REMOVEDof its solution, coexistence ones withdisconnection_infoincluded; a merchant who reconnects onboards again withreshare_after_revocation.offboardrevokes first and deletes the token second; with nothing to address and no share in the ledger it just deletes, a recorded share revocation cannot find isReconcilewith the token kept, and a pending share it revoked nothing for isRevocationIncompletewith the token kept. A recorded business is marked revoked even when nothing was ever shared with it (the owner's decision, 2026-09-25): the marker precedes any lookup, which is what stops a racing share from surviving, and funding that business later takesreshare_after_revocation.
The calls themselves are meta_whatsapp_client::credit_lines (list, share-and-attach, share, attach, receiving credential, primary funding, find records, revoke, status, and is_shared): each authenticates with its client's token, the partner's system user token except for attach and primary_funding (the merchant's business token).
Partner-led business verification is meta_whatsapp_client::business_verification (submit a merchant's business with its documents, list submissions, business verification status), outside onboarding: submitting and listing take the partner's system user token, the status the merchant's business token. Documents are checked before the request (count, type, size, content matching the type), and a submission is never replayed (Meta allows three per merchant). The decision is the account_update webhook PARTNER_CLIENT_CERTIFICATION_STATUS_UPDATE.
Multi-Partner Solutions and migration intents are meta_whatsapp_client::partners (solutions: create, accept, reject, the three deactivation calls, a solution and its partners, an app's solutions and connected client businesses, and a client's business token through a solution; migration: set_solution_migration_intent and reading an intent), also outside onboarding and each on its client's token. The business token a solution gives is an AccessToken, decoded so that a body that fails to parse reaches no error; no call there is replayed after a timeout but the reads.
Payments (India so far, roadmap P1a) are meta_whatsapp_client::payments: the order_details and order_status messages are Interactive variants of messages (re-exported there), checked against every rule the pages state, with amounts as {value, offset}; the payment lookup and refunds hang off the phone number (Client::payments), the payment configurations off the WABA (Client::payment_configurations). A refund, a configuration's creation and an OAuth link are never replayed after a timeout; the OAuth link is a SecretUrl. No call accepts the WhatsApp Payments terms of service.
EsVersion's v2, v3 and their public previews are #[deprecated]: Meta deprecates them on 2026-10-15.
Coexistence: smb_app_data sync (contacts, history) within 24h.
In-App Signup (meta_whatsapp_client::signups), WABA (waba), phone numbers (phone_numbers), business profile, QR codes, block users, commerce, analytics, marketing (MM API), flows, groups, calling, thread control (thread_control)
Typed endpoint wrappers per the rules above. Flows additionally provides the data-endpoint crypto (feature flows-endpoint): decrypt {encrypted_flow_data, encrypted_aes_key, initial_vector} with the business RSA private key (RSA-OAEP-SHA256 → AES-128-GCM), and encrypt the response with the same key and the bit-flipped IV. RSA goes through aws-lc-rs (constant-time), never the rsa crate (RUSTSEC-2023-0071, Marvin timing attack, unfixed) — the endpoint decrypts attacker-supplied ciphertext.
Webhooks (meta-whatsapp-webhooks)
POST body ─► verify X-Hub-Signature-256 (HMAC-SHA256, constant-time, any of N app secrets)
─► parse WebhookPayload{object, entry[{id, time?, changes[{field, value}]}]}
─► normalize into Vec<WebhookEvent> (one per message/status/change)
─► optional DedupGuard: lease a claim (pending, 60 s) → EventSink<WebhookEvent>
→ on SinkError::Permanent, with DeadLetters: write the dead letter, then go on
→ confirm (done, TTL 7 days + 1 h — Meta retries for 7) or release on sink errorDedup is a lease, not a marker: a marker written before delivery would swallow Meta's retry if the request died between marking and delivering (timeout, crash, deploy), losing the event for 7 days. A retry that finds a live lease gets WebhookError::ClaimInFlight (answered 503, Meta retries later). Every claim transition is a version-checked compare_and_swap, so an expired lease can't overwrite a newer claim. Dedup keys are hashed before they reach the store (group status keys contain participants' phone numbers). Logs carry sizes, digests and field names only — never payload values.
Dead letters (deadletter::DeadLetters, WebhookHandlerBuilder::dead_letters; roadmap L21a). An event the sink refuses permanently (SinkError::Permanent) is written to a typed store on KvStore, its dedup claim marked done, and the rest of the batch delivered; the delivery is acknowledged only after that write succeeded (a failed write releases the claim and answers 500). Transient and unclassified sink errors answer 500 as before, and so does a permanent one without a store. The store is bounded by count (a ring of capacity slots, 1000 by default: letter id lives in slot id % capacity, so the oldest is overwritten without a scan, with a warning), by age (each record's TTL, 14 days by default: the retention of the personal data an event holds) and by size (an event over 256 KiB serialized is kept without its content). Alert: a tracing::error! per letter (id, kind, business number; never content or the sink's error text) and DeliveryReport::dead_lettered. Replay (replay(id, sink), replay_all(sink)) hands the stored event to a sink past the dedup guard: delivered, the letter goes; refused permanently again, it stays with one more attempt. Erasure: KvStore has no scan, so the store keeps a per-contact index (every identifier the event gives: BSUIDs, parent BSUIDs, phone numbers without +; the business's own number left out) keyed by business number and contact, and DeadLetters::erase(phone_number_id, contacts); the facade's erasure::Eraser calls it beside ConversationStore::erase_all.
verify_subscription(query, &VerifyToken) -> Result<String /*challenge*/>(constant-time token compare,hub.mode == "subscribe").- Typed values for every documented field (
messages— all inbound types, statuses with pricing, errors; template status/quality/category/components updates; phone number name/quality; account update/review/alerts; business capability; security; user preferences (marketing opt-out); history, smb app state sync, smb message echoes (coexistence); partner solutions; payment configuration; calls; flows; groups; business_username_updates, user_id_update; Conversation Routing's messaging_handovers and standby, and theconversation_contextsummary on messages; the Marketing Messages API'suser_actionsclick events). Every example payload on a page that prints a webhook body is a fixture, listed with its page intests/conformance/manifest.rs, whose test checks the exact events, that every value survives the typed parse, that no value falls into a catch-all, and signed delivery through the handler (a fixture missing from the manifest fails it). Unknown fields and unknown message types parse intoUnknown { … raw: serde_json::Value }— a new Meta field must never fail a delivery. WebhookEventcarrieswaba_id,phone_number_id(when the field has one), and the user identity (wa_id?,user_id?BSUID,parent_user_id?,username?). Standby copies (StandbyObserved) are a variant of their own, never aMessageReceived: a responder that only observes a thread must not answer it.- A body that verifies but fails to parse is acknowledged (so Meta stops retrying for 7 days) and surfaced as
WebhookEvent::Unparsed{raw}+ atracing::error!carrying only size and digest. A bad signature is rejected (401); blank app secrets and blank verify tokens fail closed (a blank app secret atSignatureVerifier::new; a blank verify token at each verification, and at startup when the caller runsWebhookHandler::check, since the builder stays infallible). Default body limit: 3 MiB (Meta: payloads up to 3 MB). axumfeature:router(handler)withGETverify +POSTreceive (raw bytes, body limit), and an SSE helper that turns a broadcast subscription (filtered by phone number id) intotext/event-streamfor a live inbox. ThePOSTroute checks thatX-Hub-Signature-256is present and well-formed (sha256=+ 64 hex; no secret or body needed) before reading the body, so an unsigned request is a401without being buffered;SIGNATURE_HEADERis defined outside theaxumfeature for framework-free integrations. The SSE helper reads abroadcast::Receiver<Arc<WebhookEvent>>: every subscriber gets the same allocation of an event, never a copy of its own (roadmap L21b), and its filter runs on that shared event.
Adapters (meta-whatsapp-adapters)
http::ReqwestTransport(featurereqwest, rustls): streaming download, multipart, per-request timeout, error mapping (timeout →Timeout, connect →Connect, elseBackend), a bound on every buffered body (max_response_bytes, 256 MiB by default,Nonefor none; past it aBackenderror naming only the bound, the body never drained: an HTTP/1.1 connection is closed, an HTTP/2 stream reset). A streamed body is its reader's to bound.store::{MemoryKvStore, MemoryConversationStore}(featurememory).store::{PostgresKvStore, PostgresConversationStore}(featurepostgres, sqlx, embedded migrations,wa_table prefix configurable). Migration 4 addswa_window_events,wa_thread_owners,wa_synced_contacts(keyed by business number and contact; a window event by business number and id) andwa_identity_links, a nullablewa_messages.sender(back-filled from the payloads already stored, indexed with the business number; a row the back-fill could not read, or that the previous revision wrote, gets it from the next erasure on its number, in Rust) and the indexes purge by age reads; it changes no existing column, so the previous revision keeps working beside it (writing no sender), but itsmigraterefuses the database.erase_allandpurge_beforedelete in one statement each, under two transaction-level advisory locks (design D33): the number lock (appendandappend_syncedtake it shared,erase_allexclusive, so an append in flight is either erased with the rest or recorded after the erasure with a summary of its own, never left without one) and the purge lock (purge_beforeandtrim_conversationsexclusive,erase_allshared: purges and trims take turns, and an erasure and a purge never deadlock on rows their plans visit in opposite orders); a trim ranks each conversation's rows on the conversation index (no index of its own, no migration) and deletes, by id, only rows of its snapshot, so an append meanwhile is never lost, and a summary goes only while it still names a deleted message (an append that moved it keeps it); the group messages an erasure redacts or deletes are the number's, so the number lock covers them;set_thread_ownerandput_contactone upsert whose update applies only when the stored record is not later.store::{SqliteKvStore, SqliteConversationStore}(featuresqlite, roadmap L8; sqlx's SQLite driver with the library compiled in, no TLS; embedded migrations of their own,migrations-sqlite/, under the sameTablePrefix). For one process on one machine (a CLI, an edge or desktop deployment, a small single-instance service, tests); several processes may share the file, never over a network file system. Every write is oneBEGIN IMMEDIATEtransaction: SQLite's write lock is taken first, so each write reads, decides in Rust and writes as one step, across processes too (store::sqlite::opensets a 30-second busy timeout). Times areINTEGERmicroseconds; identifiersTEXTcompared byte for byte, refusing U+0000 as on Postgres; contentBLOB(UTF-8 bytes) and payloads JSON text, so U+0000 round-trips. The key/value store decides expiry with the application's clock (an injectableClock) and draws versions from one counter row (wa_kv_version) under the write lock.store::sqlite::memoryis a private in-memory database on one connection the pool never closes.trim_conversations(roadmap B4) is one write transaction too: it ranks each conversation's rows on the conversation index, deletes the summaries whose latest message ranks beyond the cap, then those rows; appends wait for the write lock, so none is ranked half-way or lost.store::RedisConversationStore(featureredis, roadmap L8): every key under<prefix>{conversations:<prefix>}, one hash tag per store (it names the prefix), so a store's inbox is one slot (one shard on Cluster; stores of other prefixes hash elsewhere, which spreads inboxes) and every write is one Lua script over it; status updates are a compare-and-swap decided in Rust, as on Postgres; history, the conversation list, window events and contacts page withZRANGEBYLEXover sorted sets of 16-hex-digit times and ids (byte order is time order); the scripts compare strings byte by byte, never with Lua's locale-dependent<. A purge of every number runs one script per number.trim_conversations(roadmap B4) runs one script per conversation (from a set of each number's conversations with a history,B:c:<pn>), each deleting at most 1024 of its oldest rows beyond the cap (and the summary when its latest message is one of them), so a conversation further over takes several; each is atomic with the appends, so an append is never lost. Needsnoevictionand persistence (AOF or snapshots): the history is data.media::FfmpegConverter(featureffmpeg): core'smedia::MediaConverterport over anffmpegbinary the integrator installs, run withtokio::process(no shell) and fixed literal arguments; nothing is linked. The input goes on stdin and the result comes from stdout (no file);-protocol_whitelist pipeand a-format_whitelistof recording formats keep a crafted input from making ffmpeg open a file or a URL (MP4 data references, the one reference those formats carry, are not followed: ffmpeg's defaults, pinned by a test); the process gets no environment butPATH(noFFREPORT); input, output (the upload limit), run time and any single allocation in ffmpeg (-max_alloc, 16 MiB: a header declaring an absurd sample rate) are bounded, the process killed past the output and time bounds; an MP4 whose index follows its media data is put index first in memory before ffmpeg reads it (a pipe cannot seek back); its stderr is drained and never kept (it names files); an input that decodes to no audio fails (-abort_on empty_output), and the output is checked to be mono Ogg Opus. Images (a static WebP sticker, a JPEG, a PNG; roadmap L19b) are made from a PNG or a JPEG only: checked by the input's first bytes before ffmpeg starts, and by ffmpeg's own detection (-format_whitelist png_pipe,jpeg_pipe); a PNG walked chunk by chunk first (one cut short refused, its text and EXIF chunks and anything after its end not passed on: ffmpeg inflates everyzTXtand keeps the text); the first frame, no metadata, at most 32 Mi pixels (-max_pixels) and 256 MiB a single allocation; brought under the format's limit by a fixed ladder of sizes and qualities (each attempt killed once past it, the whole ladder within the timeout), the result's header checked. An empty or oversized input (or a result over the limit after every attempt) is aValidationErroroninput; every other failure anError::Conversion(see Error tree).store::RedisKvStore(featureredis; CAS via Lua). Requires thenoevictionpolicy: every key with a TTL here enforces a limit (OTP issue logs, dedup markers), and Redis evicts silently.sink::{ChannelSink, BroadcastSink, FanoutSink, FilterSink, FnSink, TracingSink}— featuresinks, generic over the event type,Debugredacted.BroadcastSink<E>is still anEventSink<E>(the port is unchanged) but wraps each event in oneArcand publishesArc<E>on its tokio channel, so its subscribers and the SSE helper share that allocation. (The inbox sink needs webhook event types, so it lives in the facade:meta_whatsapp_rs::inbox::InboxSink.)- Postgres: one table-wide version sequence (versions never reused, even after purge); deleted keys leave marker rows purged after 10 minutes; migrations are templates with a validated table prefix and a per-prefix history table, applied under a database-wide lock. Message content keeps U+0000 (decided by the owner, formerly
OPEN_QUESTIONS.md#18): Postgrestextcannot hold it andjsonbrefuses a\u0000escape, sokind,textand the summary's preview areBYTEA(kind_utf8,text_utf8,last_text_utf8, the UTF-8 bytes) andpayloadanderrorarejson(payload_json,error_json, the text as written); migration 3 converted the oldertext/jsonbcolumns in place, in one transaction, and existing rows kept their content (a U+FFFD an older revision stored for a NUL stays U+FFFD); it refuses to run under an object of the operator's own that depends on the payload or error column (an expression index there would fail every later insert of a payload holding a NUL), and a content column that is not UTF-8 reads asStorageError::Corrupt. The cost is SQL-side: search on bytes (position(convert_to(…) IN text_utf8)), no index or field extraction on a payload holding a NUL. Ordering never involves content. Identifiers refuse U+0000: ids, contacts and phone number ids stayTEXT COLLATE "C"(Meta never assigns one with a NUL), as doKvStorekeys; memory and Redis accept it there, and the key/value conformance suite allows either answer but never another key.KvStorevalues areBYTEA.messages.idis the primary key on its own, so a message id is stored once per store whatever the business number (the memory store does the same;OPEN_QUESTIONS.md#33). The SQLite and Redis stores (roadmap L8) key a message by business number and id and keep that rule with a separate index (wa_messages_id_idx, unique;{conversations:<prefix>}:id:<id>), which ids unique per number would drop.conversation_conformance::ids_are_stored_once_per_store(outsiderun: the port allows either answer) pins today's rule; every adapter passes it. - Redis: per-namespace version counter (a per-key counter would leak one key per dedup marker forever); every mutating op is one Lua script. TLS (
rediss://, roadmap L21c) isstore::RedisTls, not redis-rs's TLS: redis-rs builds its rustls configuration from the process-wide default provider, which panics when both aws-lc-rs and ring are linked and none was installed.RedisTlsbuilds the configuration on an explicit aws-lc-rs provider, does the handshake (tokio-rustls) and hands the stream to redis-rs'sMultiplexedConnection::new_with_config;RedisTlsConnectionreconnects on its own. An integrator-built connection still works. - reqwest: errors never carry the request URL; redirects send no
Referer(it would carry the query to the redirect target), and a request that carries a credential header follows no redirect that would keep it (see "Credentials never leave Meta");HTTP(S)_PROXYis honoured. - Live tests are named
live_*, readMETA_WHATSAPP_RS_TEST_POSTGRES_URL/META_WHATSAPP_RS_TEST_REDIS_URL/META_WHATSAPP_RS_TEST_REDISS_URL(a TLS Redis requiring client certificates; its CA and client PEM files inMETA_WHATSAPP_RS_TEST_REDISS_CA,…_CLIENT_CERT,…_CLIENT_KEY) /META_WHATSAPP_RS_TEST_MONGODB_URL(a single-node replica set, withdirectConnection=true; roadmap S8), skip when unset, and fail when unset underMETA_WHATSAPP_RS_REQUIRE_LIVE=1(just test-livesets it). EveryKvStoreadapter runsstore::conformance, everyConversationStoreadapter runsstore::conversation_conformance(including concurrency, collation and real-time expiry under load). The SQLite adapters run both in process (just test); the Redis conversation store's suite is live (live_redis_conversation_*).
CMS inbox (meta_whatsapp_rs::inbox)
InboxSink (an EventSink<WebhookEvent>) records inbound messages, status updates, and coexistence echoes and history into a ConversationStore, and beside them (roadmap L7) the calls and standby messages that reopen the window, thread ownership under Conversation Routing, and the links between a customer's identities; Inbox (one per merchant phone number, built with that merchant's token) lists conversations and history, exposes the 24-hour CustomerServiceWindow and the thread's owner, and sends replies.
- The library knows WABAs and phone numbers, not the integrator's tenants. Whoever builds an
Inboxfor a request first checks that the authenticated tenant owns that phone number, before reading the token vault (whose tokens belong to every merchant). Thecms_inboxandembedded_signupexamples show where: a bearer-token stand-in for the CMS's sessions, the tenant taken only from it, and a tenant → phone number table. - Keys: business phone number id + contact, where contact is the BSUID when Meta sent one, else the
wa_id, else (group messages) the group id. - Replies to a
wa_idgo to+<wa_id>(Meta prepends the business number's country code to numbers without+).Inbox::sendrefuses a message not addressed to the conversation's contact. - Free-form replies outside the window are refused locally (
ValidationError::customer_service_window_closed(), kindCustomerServiceWindowClosed); templates and Direct Sendutilityandauthenticationare exempt (a Direct Sendservicemessage is not: Meta drops it outside the window). The window opens from the latest of the recorded inbound messages and the conversation's window events: the calls that reopen it on Meta's side (calling/pricing: the customer's call, answered or not, and the customer accepting the business's call) and the customer's messages seen in standby, which are never history nor unread (OPEN_QUESTIONS.md#32, #44). After a handover to this app newer than the customer's last recorded message, the window check lets Meta decide until that message or 24 hours after the handover (ReplyChecks::trust_handover): an app without standby copies never saw the customer's messages to the previous owner.Inbox::check_replyrunsreply's checks without sending. - Conversation Routing:
InboxSinkrecords ownership from the handovers (control_passed: this app;control_taken: another app) and from standby copies (another app); a handover names the customer by phone number only and is recorded under the key that number leads to (the identity links, else a synced contact's BSUID, else the phone number).Inbox::thread_ownerderives the rest at read time: a later message onmessagesmeans this app owns the thread (not a call permission reply, which Meta also sends to the Incoming Call primary and the standby partners), 24 hours without the customer mean it is idle;Inbox::record_releaseandInbox::record_thread_ownerrecord this app's ownrelease,passandtake, which no webhook reports to it.Inbox::sendrefuses a service message locally while another app owns the thread (ValidationError::thread_owned_elsewhere(), kindThreadOwnedElsewhere); templates and Direct Sendutilityandauthenticationneed no ownership. Both local checks are advisory (Meta enforces them) andReplyChecksturns each off per inbox: the escalation partner's ownership check, and the ownership check of an app sharing the number with a Meta Business Agent without routing. - Every rule is swappable:
InboxSink::with_recordingturns off the recording of calls, standby copies, handovers or identity links (you record that kind yourself, through the same port methods, reusing the public rulescall_window,call_key,call_status_key,handover_key);Inbox::with_thread_idle_aftersets the idle timeout;ReplyChecksswitches each local check (window, trusted handover, owner). - Identity links:
InboxSinklinks a phone number to the BSUID an inbound message carries with it, a previous BSUID and the update'swa_idto the current one (user_id_update), and a number change's old identity to the new one (asystemmessage), so thatidentitiesand an erasure reach every key of a customer; never an empty value, one with U+0000, or a value to itself. - Statuses and revokes change only a message of the business number they arrived on (
update_statustakes thephone_number_id); a revoke also only a message of its direction (ConversationStore::revoke: a customer revokes what they sent, the business what it sent). - Content never fails a delivery: it is recorded exactly, U+0000 included, and every adapter stores it (see Adapters). Storage errors still do (500, Meta redelivers the batch), as does, on Postgres, U+0000 in a Meta-assigned id of a live message (
OPEN_QUESTIONS.md#30: the inbox's sink classifies no error as permanent yet, so the dead letters of roadmap L21a do not take it; a history item with one is skipped, below). - A storage (or serialization) failure after a successful send is logged without the message's content, never returned — an error would invite a retry that sends twice.
- Revokes mark the original
Deletedif it was stored for the revoke's number and in its direction, whatever conversation key the revoke arrived with (a history thread keyed by phone number and a live revoke keyed by BSUID can be the same customer's), and keep its text and payload for the merchant's records: both the owner's decisions (2026-09-25), the first pinned by the conformance suite. A revoke that arrives before its message stores a tombstone (kindrevoked, no content,Deleted) under the message's id, so the message, live or synced, is never stored with the content its sender deleted. The tombstone is history only, never in the conversation's summary (no latest message, preview, window or unread count, and no summary of its own). A media placeholder revoked before its content arrives is never filled. - The coexistence address book (
smb_app_state_sync, roadmap L8):InboxSinkstores anadd(also how an edit arrives) as a synced contact undersynced_contact_key(the BSUID, else the phone number without+, the form conversations are keyed by, also the phone number stored) and applies aremoveunder each key it names, so that an entry keyed by the phone number before Meta sent the BSUID goes too; the store keeps the latest by the change'smetadata.timestamp, a removal kept (key and time) so that an olderaddis refused. A change without a timestamp, without a usable id, or with U+0000 in one is skipped and logged without content.Inbox::contactsandInbox::contactread them back;RecordingSwitches::contactsturns the recording off. - Coexistence (a merchant who keeps the WhatsApp Business app): echoes (
MessageEchoed, messages the merchant sent from the app) are recorded asOutbound, statusSent(the payload has none), in the customer's conversation (BSUID, elsewa_id/towithout+); an echoed revoke deletes the original. Synced history (HistorySynced) is recorded message by message in its documented direction (from= the business number →Outboundwith itshistory_contextstatus; elseInbound), each with its own device timestamp (bounded by the sink's clock plus 5 minutes, so a phone with a wrong clock cannot pin a conversation to the top), so chunks may arrive in any order and a redelivered one is a no-op (ids are stored once); the exception is a revoke in a chunk that arrives before the chunk carrying its message, which leaves a tombstone in the message's place. Each chunk is oneappend_syncedbatch (the Postgres adapter: one statement), then its revokes: a history webhook can carry thousands of messages, and one round trip each could outlast the webhook's dedup lease. A declined sync (2593109) records nothing. One malformed history item never fails the delivery: an item without a direction or customer, with U+0000 in an id, or that does not parse on its own (when one bad item turned the whole value intoWebhookEvent::Unknown) is skipped and logged by position, without content; storage errors still fail it. Synced history goes throughConversationStore::append_synced: an inbound synced message neither moveslast_inbound_at(Meta opens no window for a message sent before onboarding,embedded-signup/onboarding-business-app-users) nor counts as unread (the merchant read it in the app). The media content Meta sends after amedia_placeholder(history'smessages/message_echoes) replaces the placeholder's kind, text and payload throughConversationStore::fill_media_placeholder; the row keeps the thread's conversation, direction, status and timestamp. Both are part of the executable conformance suite, so every adapter proves them. The Postgres adapter needs no schema change: the summary is maintained when a row is written, so the difference lives in the write.
Bot framework (meta-whatsapp-bot)
A Bot is an EventSink<WebhookEvent>, so it sits behind WebhookHandler and its DedupGuard like any sink. Per event, in this order:
- Ban. A received message from a banned sender stops here: no match, no middleware (so no read receipt or typing indicator), no command, no listener.
Refusalshears of it asRefusal::Banned. - Match. For a received message: typed text or an image or video caption (unless
BotBuilder::commands_from_captions(false)) theCommandParseraccepts, or a reply button, list row or template quick-reply button whose id is a registered payload. A match setsCtx::invocation; a parsed name no command has setsCtx::unknown_command. - Middleware, in registration order; each gets the context (the match included) and
Next, and not calling it stops the event. - The command's guards, in order (scope, owner, undeclared flags on a command that declares any, cooldown last so a refusal starts none; for a subcommand, its parent's scope and owner first; a command that declares no flag reads none), then its handler; else the unknown-command handler, if one is registered; else the listeners, which get every other event too. A subcommand's cooldown is kept under the key of its path (
order status), the same digest overwa.bot.cooldown, so a command's key does not change when it gains subcommands.
Standby copies, echoes and synchronized history are other events: they never run a command. Zaileys, whose framework features this mirrors, runs middleware for commands only and after their guards; here it runs for every event, after the ban and the match.
- Traits with defaults:
Outbound(ClientOutbound, the client'smessages(pn).sendand read receipts),CommandParser(PrefixParser; it also normalizes names, so case folding is its option),AccessPolicy(AccessList),Cooldowns(KvCooldowns),Refusals(ReplyRefusals),ErrorHandler(LogErrors),MarkdownRenderer(markdown::Renderer) and itsEscape(NoEscape;WordJoinerEscapeopt-in),HelpFormatter(CategoryHelp; the help command's name, description and default category are builder options). Each has anArc<T>implementation, so one instance serves several bots. Not traits: the order above, and the reply helpers' target (the group, else the BSUID, else+<wa_id>, quoting), whichCtx::sendbypasses. - Identity is the BSUID first (
BotSender::key), elsewa_id. Bans and owners listed by phone number cannot match a sender without one. - Cooldowns are a typed store on
KvStore(namespacewa.bot.cooldown, key: SHA-256 of the length-prefixed business number, command and user key, so no phone number is stored), started withput_if_absent: atomic across instances sharing the store. A refusal writes a marker inwa.bot.cooldown.noticeexpiring with the cooldown, and only the refusal that creates it tells the user, so repeated attempts get one notice per period. Records expire in place: invisible at once, deleted by Redis itself or bypurge_expiredon the memory, Postgres and SQLite stores. - Errors: a handler's error is logged (kinds only) and acknowledged by default, because an error answers
500and Meta redelivers the whole batch, repeating replies already sent; a transient failure is then lost but for the log (the webhook dead letters of roadmap L21a take only an error a sink classifies as permanent, and the bot classifies none).PropagateErrorsopts into redelivery. TheErrorHandlergets the context from before the middleware (the match set, a middleware's values not). AfterBot::unload, delivery fails withSinkError::Closed. - Plugins are compiled in (
Plugin::setupregisters commands, middleware and listeners;on_unloadatBot::unload). No dynamic loading or hot reload: Rust has no stable ABI. - Command menu:
Bot::sync_command_menusends the visible commands through the client's conversational automation call, under its limits (30 commands, names up to 32 characters, descriptions 1 to 256). - Markdown:
markdown::Renderer(pulldown-cmark) converts CommonMark to WhatsApp formatting and packs blocks into messages of at most 4096 (the client'sTEXT_BODY_MAX_CHARS), measured in UTF-16 code units since Meta does not say which unit it counts (never fewer than the client's count of characters), never cutting a code block that fits. Code a fence cannot hold and emphasis inside a word go out as plain text. A table is padded columns while a padded row fits 60 characters, else oneheader: valueline per cell, and either only while it is at most twice the table's unpadded rows (or one message), else those rows: a reply's length, so its number of billable sends, stays proportional to the Markdown's (both limits areRendereroptions). Meta documents no escape syntax: the default leaves text as written, so copied addresses, codes and commands work;WordJoinerEscape(U+2060 around literal markup characters, copied along with the text) is the opt-in. - Listeners name what they get: received messages, one message
type, an event kind (checked againstWebhookEvent::KINDSat build), or everything. - Paced broadcasts (
broadcast,pacer): aBroadcastsends one message, or one per recipient, from one business number through anOutbound, each send (retries included) after a slot of the number'sPacer, up to a configurable number in flight;runreturns a report per recipient, and aBroadcastHandlegives progress and a cancel (no send starts after it, and the slots waited for go back; sends in flight finish). The pacer is aRateLimiter(defaultTokenBucket: per number, in this process, evenly spaced atRate::DEFAULT, 80 a second, Meta'sthroughputdefault, with per-number rates settable while running, a burst and a slow-down on throttling whose factor, spacing and recovery are settings), aSlowDownRule(which errors slow a number down, defaultThrottlingErrors) and aTimer(aClockthat can wait until a deadline:SystemClockon Tokio,ManualClockin tests). A limiter reserves (takes aSlotRequest: number, now, cost; returns aReservationwith the wait) rather than waits, and can take an unused slot back, so a shared one, across replicas, is aRateLimitertoo:KvRateLimiter(opt-in, roadmap B2b), a typed store onKvStore(namespacewa.bot.pacer, no new port) keeping each number's schedule, slow-down and raised rate as a versioned record booked bycompare_and_swap, a bounded number of attempts under contention (thenStorageError::Busy), failing closed when the store does, and assuming the replicas' clocks synchronized (a skew lets up to⌈skew × rate⌉more into a one-second window); without it each replica has its own budget.RateLimiter::raise_rate(default: raises nothing) is how a throughput upgrade reaches a limiter, in this process (TokenBucket) or for every replica (KvRateLimiter), never lowering a rate. Failed sends go to aBroadcastPolicy(defaultBackoff, onErrorKind: the pair limit defers one recipient on Meta's4^Xschedule, throughput retries, a number in maintenance (131057) is retried every 20 s, the per-user marketing limit is reported, number-wide kinds stop the run and so do a template's own refusals when everyone gets the same message; every delay capped), but a send is repeated only whenError::may_resendholds, whatever the policy (enforced outside it; the client's rule too).BroadcastBuilder::clientturns the client's own replays off (Client::with_retry), so every retry is paced. A person listed twice is sent once (BroadcastBuilder::dedupe); each line can go to aReportSinkas it settles instead of the report (BroadcastBuilder::report_to), so memory does not grow with the lines; the run keeps its own time, so a wall clock stepping back neither pauses the pacer nor puts off a retry.BotBuilder::pacerwraps a bot's outbound in aPacedOutbound, so its replies, read receipts and typing indicators share the budget and slow it down when throttled (a bot built on a client retries there, each retry paced, instead of in the client);Ctx::pacerhands the pacer to handlers;PacedGroupsandPacedGroupwrap the client's group operations the same way, andPacer::acquireis the hook for any other call. No new port and no persistence: a run lives in memory. - Durable jobs (
jobs, roadmap B3; design D29):KvJobskeepsJobs (a number, a due time, the recipients each person once, a JSON payload) as a typed store onKvStore, no port of its own.KvStorehas no range scan, so due times are bucketed by the minute: a job's id is appended to its minute's page by compare-and-swap (256 ids a page, then the next page; a due time already past, to the current minute's), and acursorrecord names the oldest minute still to read. AJobRunnerreads from the cursor up to now, claims each due job by compare-and-swap (pending, or claimed under an expired lease, to claimed with a lease naming it), and sends through a pacedBroadcastwhose outbound checks before each send that the job is still its own and not cancelled (renewing the lease) and creates the recipient's marker withput_if_absentbefore the request, then reads the job once more (a cancel or a re-run meanwhile: no request): a "sending" marker nobody completed is an outcome unknown, never resent (at most once, not exactly once). It moves the cursor past a minute whose jobs are all settled, keeping that minute's page versions in it, reads that minute again and moves it back if it changed meanwhile (every tick starts by the same second read, so a runner failing between the two loses nothing), and indexing into a minute behind the cursor moves the cursor back, so no job is skipped by a race. A pass that leaves recipients provably unsent (Error::may_resend, a stop, a store failure) is retried with backoff, then stopped. A message's id is kept for its status webhooks: a131049marks that recipient for 24 hours (KvJobs::observe,JobFeedback), and a re-run (KvJobs::rerun) skips them. The job store is not behind a trait yet (theKvStoreadapter is what swaps; aJobStoretrait is roadmap B3b); the runner's outbound, pacer, failure policy and composer are.
Typst (meta-whatsapp-typst)
Render a Typst source with JSON inputs (sys.inputs.data, read with json(bytes(sys.inputs.data))) to PDF or PNG, with bundled fonts so output is byte-identical on every machine. today() comes only from Renderer::with_today (never the system clock). The world is a sandbox: no file reads beyond the main source, no packages, no network; PNG size is bounded (MAX_PNG_PIXELS) and ppi validated before any allocation; the main file is always /main.typ (typst interns file ids process-wide). Ships templates for e-commerce: invoice, receipt (order confirmation), voucher (coupon / gift card image for marketing headers). Output is bytes + MIME + filename, ready for media().upload() and a document/image message or template header. Never renders OTP codes (those go through authentication templates only).
Service (meta-whatsapp-server)
Apps not written in Rust use meta-whatsapp-rs through a service, deployed as a container and called over HTTP. Its design, with the owner's decisions and the delivery milestones, is docs/design/server.md; what is built so far is in docs/coverage.md. The rules that bind it to the library:
A family of crates on the facade. The service's crates depend on
meta-whatsapp-rsand on each other, never on another crate of the library, and no library crate depends on them. Each ispublish = false, a workspace member (sojust cicovers it) but not a default one. What the service cannot build from the facade, an integrator could not either: the gap is a library change of its own, not a private shortcut.crates/meta-whatsapp-server-core: the framework-free core. The domain (tenants, keys, bindings, idempotency records), the authorization order (credential toCaller; ownership toOwnedNumber/OwnedWaba, the only way to a vault token; each capability works only with theAuthorizerthat made it, which refuses another's with403; an adapter that takes a capability from outside its own code callsAuthorizer::admitoradmit_adminbefore acting on it, as the service's extractors do: design §8.1), event routing and polling, the idempotency engine (a key's fingerprint the operation and its canonical input, since S6), the rate limiter (a request charged by its operation's class, since S6), the error model as data (ServiceError: anErrorCode, one constant per row ofCODES, generated from one table since S6, its status as a number,retryable,may_have_been_sent), and the operations as services an API adapter parses for, calls and renders: since roadmap S5a the admin ones (admin::Admin, opened with anAdminCalleritsAuthorizeradmits), since S5b the messages (messages::Messageson anOwnedNumberitsAuthorizermade and admits, over the send requestmessages::SendMessage, which names no API toolkit's type: an adapter converts its own request into it) and running an operation under an idempotency key (idempotency::run, for aCalleritsAuthorizeradmits: a status, a body, and whether it replayed), since S5c the numbers (numbers::Numberson an admittedOwnedNumber: details, the business profile read and changed;numbers::Wabaon anOwnedWabaitsAuthorizeradmits,admit_waba: disconnecting) and the templates (templates::Templateson an admittedOwnedWabaof the admittedCaller's own tenant, over the adapter'stemplates::TemplateCache: the list, the lookup of an id in its WABA, create, delete), and since S5d the media (media::Mediaon an admittedOwnedNumber, with the adapter's transfer slots: anUploadchecked before any request, a download verified against Meta's SHA-256, whole or asmedia::MediaStream, afutures::Streamof the library'sBytesheld one chunk behind, which ends with aStreamStoppedinstead of its last chunk on a mismatch, and a deletion of the number's own media only), and since S5e the webhook pipeline (inbound::Pipeline, built on aBackendalone: a delivery's signature checked against every app secret before any store is touched, then the library'sWebhookHandler, each event under its dedup lease, routed by the records, never by the payload, into the inbox and the outbox; the answer,Deliveredor aRefusedwith its status, only once every event is recorded), and since S6 the tenant operations by id (operation::Operation: the HTTP adapter'soperationIds), which a request's rate class and its idempotency fingerprint follow whatever the adapter, with theAuthorizer's writes to the vault reached only throughadmin::Admin. It depends on the facade without its adapters' features: no axum, no sqlx, no utoipa (thehttpcrate is in its graph only through the library, whoseHttpTransportport and client speak it; nohttptype crosses the core's API).- The backends (roadmap S7), each on the core and the facade alone:
crates/meta-whatsapp-server-store-memory(MemoryBackend, development and tests) andcrates/meta-whatsapp-server-store-postgres(PgBackendthrough sqlx, the facade'smeta_whatsapp_rs::adapters::store::postgres::sqlx, with the service's migrations and their migrator). Each runs the core's conformance suite in its own tests (live for Postgres). crates/meta-whatsapp-server-http: what every API adapter shares over HTTP (axum throughmeta_whatsapp_rs::webhooks::axum): the state (AppState, composed from aBackend, and theGrantits composer is handed, the only way an API adapter's handlers reach the records, the authorization order and the idempotency engine), the listeners' accept loop, telemetry and metrics, authentication before the body (the guards and extractors), the §5 renderer of the core's errors, the webhook listener (the signature header's shape before the body, the places, the body's size and read time, the refusals' log, over the core's pipeline), media, and the operations routes.crates/meta-whatsapp-server-api-axum: today's/v1resource routes and the OpenAPI document generated from them (utoipa), with the HTTP crate's media and operations groups; permanent, the swap target (design D26).crates/meta-whatsapp-server(binarymeta-whatsapp-server): the process (configuration,serve, the command line), composing one API adapter (featureapi-axum) and the backends the command line can open (store-memory,store-postgres), all three by default. It re-exports the moved modules under their old paths (meta_whatsapp_server::api,::state,::store, …), and keeps the committedopenapi/v1.json, which itsopenapicommand prints.
textmeta-whatsapp-server (binary) ─┬─► meta-whatsapp-server-api-axum ─► meta-whatsapp-server-http ─┐ ├─► meta-whatsapp-server-http ──────────────────────────────────┤ ├─► meta-whatsapp-server-store-memory ──────────────────────────┤ └─► meta-whatsapp-server-store-postgres ────────────────────────┴─► meta-whatsapp-server-core ─► meta-whatsapp-rs (facade)No store crate depends on the HTTP crates, and the HTTP crate depends on no API adapter or backend (dev-dependencies aside: its unit tests run on the memory backend). Every log line of a moved module keeps its target,
meta_whatsapp_server::<module>as it was before the split (explicit since S7): an operator's filters name them.Ports, and a backend as the unit of swapping. The core stores through ports:
RecordStore(tenants, keys, bindings),IdempotencyRecords,Outbox,LeaderLock(a lease,try_exclusive(name, lease)),Janitor(expired rows nothing else deletes) andSchemaMigrator. ABackendgives all of them, and the library'sKvStoreandConversationStore, over one database, because they share its guarantees (deleting a tenant reaches its idempotency records and its event stream; every outbox insert checks the routing again against the bindings, atomically with the insert). The service has two, memory and Postgres, and is composed from a backend alone (roadmap S4): its state (AppState::from_backend) builds the token vault and the webhook pipeline on the bundle's stores,serve_withserves, migrates and closes it, housekeeping runs over it (the core'shousekeeping), and the command line opens it through aBackendFactory, so no constructor takes a port on its own and a backend an integrator brings needs no change to the server. No port names a database driver's, an HTTP framework's or an API toolkit's type.What every backend guarantees, as the ports' contracts require it (roadmap S2, so that a MongoDB or a CrateStack-models backend cannot meet the traits without it; the
meta_whatsapp_server_core::storemodule documents each): the outbox keeps a row's tenant only while the binding it was routed by (a typedRouteGuard) still holds, checked with the insert; binding a WABA to a missing tenant isBindOutcome::NoSuchTenant, and a binding and a deletion of one tenant serialize; one clock all replicas agree on (the database's, else the service'sClock, which the core'sAuthorizertakes injected); contention is the library'sStorageError::Busy; purges take no lock of their own, under oneLeaderLockturn per housekeeping round; listings are in byte order; eachBackendaccessor hands out the same data on every call; and a capability (OwnedNumber,OwnedWaba) writes only to the binding (aBindingEpoch) and the vault record it was made from, and is made only from a token read while that binding held. That last guarantee rests on a rule between the bindings and the library'sTokenVault, which no transaction spans: the vault is written for a WABA only by its current holder, an attach binds, stores, then confirms (it reads its binding again and, moved meanwhile, takes back exactly its own write), and every unbind deletes the token before the binding. These contracts are executable (roadmap S3):meta_whatsapp_server_core::conformance(featureconformance) runs them over aBackend, and both in-tree backends pass it in the service's tests (Postgres live), as a new backend must.One multi-tenant deployment per Meta app (the owner's decision D1): every merchant onboarded through the app delivers to its one callback URL. Tenants are the integrator's ids; a tenant owns WABAs, a WABA owns phone numbers, and the business token stays in the library's
TokenVault, keyed by WABA.Two listeners. The public one serves Meta's webhook and a liveness probe, nothing else; the internal one (loopback by default) serves the API, readiness, metrics and the OpenAPI document.
Credentials (D2): tenant keys, and platform keys that name a tenant per request (
WA-Tenant), within their allowed set; an admin key for/v1/adminonly. Keys are stored as SHA-256 digests and compared in constant time.Ownership before the vault. Every route that names a number or a WABA checks, in this order: the key, the tenant, the scope, that the tenant owns the number or WABA (else
404, the answer for one that does not exist), and only then reads the vault and callsClient::with_token. The library leaves this check to the integrator (see CMS inbox); the service is one. A token may reach several tenants' WABAs, so an object a path names by id (a media id, a template id) must also be the number's or WABA's own, established through the number or the WABA (phone_number_id={pn}on every media call; a template id found in the WABA's own template list). The bare id is only used to read its name (a template's, which that list is searched by), never to act on it or to answer anything of it; one that is not the number's or WABA's is404like a missing one. A route for a new kind of id (flows, groups, QR codes) owes the same check.Errors keep the library's classification. A Graph failure's error code is
ErrorKind::as_str(); its HTTP status comes from a table the service owns, tested overErrorKind::ALLso a new kind cannot fall through unmapped;may_have_been_sentandretryableareError::may_have_been_sentandError::is_retryable. Meta's error message, title and user texts never reach a response;graph.detailsis Meta's text, not the service's, kept only on the routes that opt in (never OTP or signup), bounded and without control or format characters or line separators.Sends are never repeated behind the caller's back. The service adds no send retries to the library's; a caller's
Idempotency-Key(sends, uploads, template creation) keeps the answer of a request that may have taken effect and replays it, and releases the key only when the library'sError::may_have_been_sentisfalse. A key's fingerprint is the operation and its canonical input (the number or WABA as a decoded id, the body canonical), never the request's method or path, so a key means the same through every API adapter (design D27). Fingerprints are stored with their key but are not stable identifiers: records lastWA_SERVER_IDEMPOTENCY_TTL, and a fingerprint derived otherwise after an upgrade answers a repeat across it422 idempotency_key_reusedfor that long, never a second send, unless the release also matches the fingerprint of before, as S6's does for one TTL (Fingerprint::or_legacy, until the first release).Webhooks in go through the library's
WebhookHandler(signature before parsing, 3 MiB, the leasedDedupGuardin the sharedKvStore) intoInboxSink, then the service's event outbox, one stream of sequences per tenant. Routing to a tenant is an allow-list: the tenant bound to the event's number or WABA since before Meta dated it, for the event types the service reviewed; everything else is an operator-only row.Storage is Postgres (memory only in development). The service's tables are
wa_server_*with their own migration history, run with the library's migrations under an advisory lock of the service's own; migrations are expand-then-contract so replicas of two versions can share a database during a rolling deploy.
Verification
just ci is the gate (lint, check, test, doc, per-feature builds, deny, live adapter tests). CI runs exactly it. See AGENTS.md.