Skip to content

Production ​

Goal: run meta-whatsapp-rs on several instances without losing webhooks or tokens, without leaking secrets or customer data into logs, and without being surprised by Meta's limits or API versions.

Agent skills: meta-whatsapp-rs-production, meta-whatsapp-rs-storage. Design background: architecture.md (adapters, error tree, security rules).

1. Storage ​

Everything stateful sits on two ports. The typed stores are built on KvStore, so one adapter serves them all:

DataPortNamespaceLose it and
merchants' business tokens (TokenVault)KvStorewa.tokenevery merchant reconnects
Embedded Signup attempts (SignupSessions)KvStorewa.es.sessionattempts in flight fail
OTP challenges and issue limits (OtpService)KvStorewa.otp, wa.otp.ratecodes in flight fail; limits reset
webhook dedup (DedupGuard)KvStorewa.webhook.dedupMeta's retries are delivered again
inbox history (InboxSink, Inbox)ConversationStoretables wa_*the history
MemoryPostgres (postgres)SQLite (sqlite)Redis (redis)
KvStoreMemoryKvStorePostgresKvStoreSqliteKvStoreRedisKvStore
ConversationStoreMemoryConversationStorePostgresConversationStoreSqliteConversationStoreRedisConversationStore
survives restarts, shared by instancesnoyessurvives restarts; the processes of one machineyes, with persistence on
expiry clockthe processthe database serverthe processthe Redis server
maintenanceapply_retention (or a bot RetentionSweeper: by age and a cap per chat) on a schedule, when a retention is setmigrate at startup; purge_expired() every few minutes to hourly; apply_retention (or a bot RetentionSweeper) on a schedule, when a retention is setmigrate at startup; purge_expired() now and then; apply_retention (or a bot RetentionSweeper) on a schedule, when a retention is seteviction policy noeviction, on an instance of its own; apply_retention (one script per number) or a bot RetentionSweeper (one script per conversation) in quiet hours
limitsone instancerefuses U+0000 in ids and keys (message content keeps it, as bytes and json: search on bytes, no payload indexes)one machine, never a network file system; refuses U+0000 in message ids, contacts and phone number ids; writes one at a timea conversation store's inbox on one shard (one hash tag); TLS through RedisTls

Postgres alone covers everything and is the simplest choice. Put the vault on Redis only with persistence (AOF or RDB): a Redis that forgets on restart forgets every merchant's token. And only with noeviction: every key with a TTL there enforces a limit (OTP issue logs and cooldowns, dedup markers, signup sessions), and a volatile-* policy evicts them silently under memory pressure, handing out more OTP codes and delivering Meta's retries twice. A full Redis then fails writes (StorageError::Backend): size maxmemory for 7 days of dedup markers.

rust
use std::sync::Arc;
use std::time::Duration;
use meta_whatsapp_rs::adapters::store::postgres::{self, PostgresKvStore, sqlx};

let pool = sqlx::PgPool::connect(&database_url).await?;
postgres::migrate(&pool).await?; // idempotent, takes a lock: any instance may run it
let kv = PostgresKvStore::new(pool.clone());
let purger = kv.clone();
tokio::spawn(async move {
    let mut every = tokio::time::interval(Duration::from_secs(600));
    loop {
        every.tick().await;
        if let Err(e) = purger.purge_expired().await {
            tracing::warn!(error = %e, "purging expired meta-whatsapp-rs rows failed");
        }
    }
});
let kv: Arc<dyn meta_whatsapp_rs::core::store::KvStore> = Arc::new(kv);

An upgrade across a migration that rewrites tables (migration 3, lossless message content) is the exception: run migrate once from a one-off job first (section 7).

Redis over TLS (rediss://) goes through RedisTls (feature redis), not through redis's own TLS features, which meta-whatsapp-rs leaves off: redis-rs builds its TLS configuration from rustls' process-wide default crypto provider, and when a binary links both of rustls' providers (aws-lc-rs, which reqwest and sqlx use here, and ring, which many other crates bring) with no default installed, the first TLS connection panics. RedisTls names aws-lc-rs itself, so it needs no default and ignores the one you installed:

rust
use meta_whatsapp_rs::adapters::store::{RedisKvStore, RedisTls};

let tls = RedisTls::builder()
    // A private CA; leave it out to trust the system's roots.
    .root_certificates_pem(std::fs::read("/run/secrets/redis-ca.crt")?)
    // If your Redis asks for a client certificate (`tls-auth-clients yes`).
    .client_auth_pem(
        std::fs::read("/run/secrets/redis-client.crt")?,
        std::fs::read("/run/secrets/redis-client.key")?,
    )
    .build()?;
let kv = RedisKvStore::new(tls.connect(&std::env::var("REDIS_URL")?).await?);
  • Trust: the system's trust store by default, as the reqwest transport does (in a scratch or distroless image with no CA bundle, build fails: pass the CA). root_certificates_pem trusts those certificates instead of the system's. The certificate must name the URL's host: an IP address (rediss://10.0.0.5) needs an IP address in the certificate, a DNS name never covers it. RedisTls::builder() has no mode without verification: a URL with #insecure is refused.
  • The connection reconnects on its own: the command that meets a dropped connection fails (it is not replayed, a compare-and-swap included) and the next one opens a new connection, which authenticates and selects the database again. Unlike ConnectionManager, it neither retries nor backs off: one attempt per command that finds no connection, bounded by the connection timeout. Timeouts are redis-rs's (1 s to connect, the handshake included, 500 ms per reply): with_connection_timeout, with_response_timeout.
  • Your own TLS configuration (another verifier, your own provider): RedisTls::from_client_config, used as it is: a verifier of yours that accepts any certificate turns verification off, and that is your call. A connection you built yourself, with redis's TLS features and a provider you installed, still goes to RedisKvStore::new as before.
  • Redis Cluster and Sentinel over TLS are not covered.

Every store adapter runs an executable conformance suite; just test-live runs it against real Postgres and Redis.

2. Secrets and keys ​

SecretUsed forKeep itRotate
system user tokenyour own number (OTP, order messages)secret managergenerate a new one in Business Settings, deploy, revoke the old
app secretwebhook signatures, the code exchange, the app token for debug_tokensecret managerlist old and new in SignatureVerifier::new while rolling out, then drop the old
verify tokenwebhook GET verificationsecret managerchange it in the dashboard and in your config together
vault key(s)encrypting merchants' tokens, and a Solution Partner's credit ledgersecret manager, not the vault's databaseVaultKeys::new(new).with_previous(old), vault.rotate(&waba_id) for every WABA ever onboarded (offboarded ones too: the credit ledger outlives the token), vault.rotate_business(&business_id) for each business revoked by business id alone, then drop the old key
OTP pepperkeyed hashes of codes and numberssecret manager, not the OTP databaseinvalidates outstanding codes and resets limits
merchants' business tokensacting as a merchantthe vault onlymerchant reconnects (no refresh)
two-step PINsregistering numbersnot stored by meta-whatsapp-rs; the examples ask the merchant per attemptasked per attempt, never stored (decided 4)

AccessToken, AppSecret, VerifyToken, SecretBytes, SignupCode, TwoStepPin, OtpPepper and VaultKey print [REDACTED] (or only an id) in Debug; the value comes out only through expose_secret(). Call it at the boundary that needs it and nowhere near a log line. A link Meta answers with that opens something for whoever holds it (a Flow's preview link, a Flow asset's signed download link) is a SecretUrl: its Debug keeps the host and path and prints <redacted> for every query value, user info and fragment; expose_secret() gives the whole link, and so does serializing it, so do not log a serialized response that holds one. A media download URL (MediaInfo::url) stays a String, since it opens nothing without the token and expires after five minutes, but MediaInfo's Debug (and so MediaDownload's, VerifiedDownload's and DownloadedMedia's) shows it the same way: its signature is credential-shaped and logs outlive it. DownloadedMedia's Debug shows the file's length (data_len), not its bytes. Inbound webhooks carry the same links: MediaContent::url (a media message's, an echo's, a call recording's) and the Meta CDN links of a Click to WhatsApp Referral (image_url, video_url, thumbnail_url) stay Strings too and print that way, so a logged WebhookEvent or payload holds no signature in those fields; serializing one still writes them whole. What the crate did not parse is kept, and printed, as Meta sent it: the raw JSON of an Unknown field or message type and the body of an Unparsed event can hold such a link, so do not log those whole. Both crates use meta_whatsapp_core::secret::RedactedUrl for it, which you can use for your own types. A group invite link (GroupUpdate::invite_link in a webhook, InviteLink::invite_link from Group::invite_link and reset_invite_link) carries its code in the path, so its Debug keeps only scheme and host (https://chat.whatsapp.com/<redacted>), through meta_whatsapp_core::secret::RedactedPath, and an invite link answer that does not decode gives an Error::Decode without the body.

The client attaches a token to the configured Graph endpoint (scheme, host and port), to https://lookaside.fbsbx.com, Meta's media download host, and to one URL on api.facebook.com, the Parent BSUID Accounts API: GET https://api.facebook.com/{business id}/parent-bsuid-accounts (no other path, method, scheme or port on that host). A request to any other URL with a token fails locally (Error::Validation on url), and so does a credential header set by hand (GraphRequest::header refuses Authorization, Proxy-Authorization and Cookie). The check covers the URL the client asked for; the stock transport follows no redirect that would carry the token (a hop that keeps scheme, host and port: the 3xx is an Error::Http), and follows one to another origin without it. Behind a Graph proxy (ClientBuilder::endpoint), the token goes to the proxy, the media host and that one URL, not to graph.facebook.com; if every request must leave through your proxy, configure it on the transport instead. HTTPS_PROXY keeps the stock transport's redirect rule. For a proxy set in code, build two proxied reqwest::Clients, both with .referer(false), the second also with .redirect(meta_whatsapp_adapters::http::credential_redirect_policy()), and pass them to ReqwestTransport::with_clients(plain, credentialed): the second sends every request that carries a credential. A single client given to ReqwestTransport::with_client (deprecated) sends every request, the token's included, with its own redirect policy, which the transport cannot change: build it with credential_redirect_policy() or reqwest::redirect::Policy::none(), never reqwest's default, which forwards the token on a redirect within an origin. A URL with a user name or password is refused before sending (Error::Validation on url): it would go out as a credential no rule checks.

3. Logs and observability ​

meta-whatsapp-rs logs through tracing; install a subscriber and filter with RUST_LOG:

rust
tracing_subscriber::fmt()
    .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) // RUST_LOG=info,meta_whatsapp_client=debug
    .init();
LevelWhat
debug (meta_whatsapp_client)each Graph request (method, path, attempt); each retry (error kind, Graph code, delay); OTP challenges issued and verified (challenge id only)
warn (meta_whatsapp_webhooks)rejected deliveries (signature, size) and verification requests; changes kept untyped (field name); sink failures and events in flight elsewhere (the non-200 answers); dedup leases that expired before the event was marked done
warn (meta_whatsapp_client)the token vault failing to re-encrypt a record under the active key (retried on the next read)
errorsigned bodies that are not webhooks (size and SHA-256 only); dedup markers that could not be written or released; a reply sent but not recorded in the inbox

Never logged: tokens, the app secret, Embedded Signup codes, PINs, OTP codes, request query strings (they can carry client_secret or a code), and webhook payload values. Errors from the code exchange drop any text that could contain the URL, and an unreadable send response (Error::Decode from Messages::send, Marketing::send or an OTP issue) carries neither the body nor serde's message: the response names the recipient. Keep it that way in your code: do not log request bodies, the signature header, WebhookEvent's Debug, or anything from expose_secret(). TracingSink::new() logs event kinds only; with_payload(true) logs customer data. Request paths at debug contain WABA and phone number ids: identifiers, not secrets.

Worth a metric: WebhookEvent::kind() counts, the DeliveryReport (delivered, duplicates, unparsed), webhook answers by status (a stream of 503s means sinks outlast the dedup lease), err.kind() of failed sends, and the count of Unknown events.

4. Limits, throughput and retries ​

Limit (Meta)ValueIn meta-whatsapp-rs
messages per number80/s by default, up to 1,000; inbound messages count tooRateLimited (130429), replayed within the retry budget; a batch paced per number by the bot crate's Broadcast and Pacer (bots.md)
same userabout one message per 6 s, short bursts borrowed from laterPairRateLimited (131056), replayed within the budget; Meta suggests backing off 4^n seconds after that, which the paced broadcast does for that recipient only
templates to new usersportfolio messaging limit, 250 to unlimited per rolling 24 hnot tracked: count in your campaign queue
management endpoints200 requests/hour per app per WABA (5,000 for active WABAs)don't list templates or phone numbers per request: cache them
number registration10 per 72 h133016 locks for 72 h, never retried
webhooksanswer with median ≤ 250 ms; size for ~3× outbound + 1× inboundsee webhooks.md

The client's defaults: 30 s timeout (DEFAULT_TIMEOUT), 3 retries with full-jitter backoff from 250 ms to 8 s. Change them once, at startup:

rust
use std::time::Duration;
use meta_whatsapp_rs::RetryPolicy;

let client = meta_whatsapp_rs::client_builder()?
    .timeout(Duration::from_secs(15))
    .retry(RetryPolicy { max_retries: 2, base_delay: Duration::from_millis(500), max_delay: Duration::from_secs(4) })
    .build()?;

Response bodies: a media download's body streams, and what bounds it is yours (Media::download_bytes's max_bytes, or the reads you make of Media::download's stream). An error answer to a download (an expired URL, a proxy's or CDN's error page) is read to at most 64 KiB, then the connection is dropped; Meta's error envelope is far smaller, so its Error::Api is unchanged, and a longer body is an Error::Http with its first bytes. That bound is the client's, on what the transport streams: ReqwestTransport streams, and holds at most what it has received and not yet handed over (a read buffer of a few hundred KiB on HTTP/1.1, up to the 2 MiB stream window on HTTP/2); a transport of your own that keeps the port's default send_streaming buffers the whole body through send first. Every other request's answer is buffered by the transport, and ReqwestTransport bounds it: at most max_response_bytes (builder setting; ReqwestTransport::with_max_response_bytes for one built with with_clients), 256 MiB by default (DEFAULT_MAX_RESPONSE_BYTES), None for no bound, for 2xx and error answers alike. Those answers come from the configured Graph endpoint or proxy, from api.facebook.com for the Parent BSUID Accounts call, from a URL you pass to Client::request_url, and from wherever a redirect to another origin leads (followed without the token, up to 10 hops), so the bound is what keeps a hostile or broken one from making the process buffer any size. A Content-Length over it is refused before the body is read (not on an answer that has none: to a HEAD, a 204 or a 304), and the body is read chunk by chunk and refused as soon as it passes it (a body without a length, chunked or ended by closing, included), on HTTP/1.1 and HTTP/2; the answer is a TransportError::Backend saying response body exceeds <n> bytes, never the body or the URL, not retried, and the rest is not read: an HTTP/1.1 connection is closed, an HTTP/2 stream reset. The default leaves room for the largest JSON the client asks for: analytics field expansions (Analytics::pricing, conversation, calls) are unpaged, and a year of HALF_HOUR points broken down by several dimensions can run to a hundred MiB; split such a range or raise the bound. Lower it if nothing you call answers that much. A transport of your own should bound its send too.

Sends are never replayed after a timeout or a 5xx. A campaign or notification queue on top must be idempotent itself: tag each message with callback_data, and before resending look for its status webhook (see getting-started.md).

5. Graph API version ​

  • ApiVersion::DEFAULT is v25.0, the version Meta's docs used on 2026-09-24, and the one meta-whatsapp-rs was tested against. Pin meta-whatsapp-rs by rev and the version moves only when you move the pin.
  • To hold a version across a meta-whatsapp-rs upgrade: .api_version(ApiVersion::new(25, 0)) on the builder (meta_whatsapp_rs::core::config::ApiVersion). Use the same value in the Embedded Signup page's FB.init.
  • Before moving: read Meta's changelog for the new version, rerun your integration tests against a test WABA, and watch the Unknown count: webhook fields Meta adds arrive as Unknown instead of failing.
  • Embedded Signup v2 and v3, including their public previews, are deprecated on 2026-10-15 (the matching EsVersion values are #[deprecated]); LaunchOptions targets v4 (the login configuration selects it).

6. Several instances ​

  • Shared stores for everything in section 1; the memory stores give each instance its own dedup, limits and vault.
  • Clocks: Postgres and Redis decide expiry by their own clock; keep all hosts on NTP.
  • One Client per process, cloned; with_token per merchant. Each meta_whatsapp_rs::client() call creates a new connection pool.
  • Live inbox: the broadcast channel behind SSE is per process. Relay events between instances yourself (Postgres LISTEN/NOTIFY, Redis pub/sub), or pin each merchant's browser and webhooks to one instance.
  • Queues: a ChannelSink queue lives in memory and dies with the process. Persist before acknowledging anything you cannot lose.
  • Deploys and crashes: a webhook delivery cut mid-way leaves a dedup lease that expires after 60 s; Meta's retry then delivers it. Nothing to do, as long as sinks are idempotent.
  • Load balancer or proxy in front of the webhook: HTTPS with a valid certificate, body limit at least 3 MiB, no body rewriting or decompression (the signature covers the raw bytes), and a timeout longer than your slowest sink.

7. Before going live ​

  • Business verified; payment method in WhatsApp Manager (yours and each merchant's); templates approved in every language you send.
  • Tech Provider: App Review passed with Advanced access; Embedded Signup domains and configuration set (embedded-signup.md).
  • Solution Partner (embedded-signup.md):
    • the system user's token, id and your credit line id in the secret manager and configuration; a currency for every merchant (or a default);
    • the approval gate: onboarding through onboard_with_approval (plain onboard is refused), reserving the WABA for the merchant in one atomic write, and resume_with_approval for tokens stored before the deployment became a Solution Partner;
    • PartnerRemoved wired to revoke_credit_line (to revoke_business_credit_line when it names no WABA) at once, a coexistence disconnection included (the owner's decision, 2026-09-25; a merchant who reconnects onboards again with reshare_after_revocation), ignoring one whose waba_info.solution_partner_business_ids does not list your business (a Multi-Partner Solution you are not in);
    • PartnerAppUninstalled wired to offboard, only when its waba_info.partner_app_id is your app id;
    • key rotation walking every WABA ever onboarded, offboarded ones included, and every business revoked by id (rotate_business), collecting failures instead of stopping at the first: the credit ledger is sealed with the vault keys;
    • alerts on CreditError::Reconcile and on a CreditError::RevocationIncomplete that is not retryable: both need a person and Meta Business Suite (both are ErrorKind::Unknown); a retryable one (Meta has not confirmed a DELETE, a pending share not found yet, a ledger write) is called again later; a share whose answer was lost (Reconcile) is never retried at once;
    • an admin action for a pending share Meta never lists (a revocation that keeps answering share_pending), on a staff-only route: after checking the WABA's funding in Meta Business Suite, clear_pending_share with the operator id of the authenticated staff session (not a name or an email: it is sealed in the ledger for as long as the WABA's credit record), acknowledging a funding it reports only once Business Suite shows it is not your line;
  • Webhook fields subscribed; alerts wired (webhooks.md).
  • Secrets from the secret manager, none in the repository or the database.
  • Inbox history's retention chosen (kept by default; with_retention on the conversation store and a scheduled apply_retention, or a RetentionPolicy with an age and a cap per chat run by a RetentionSweeper, bots.md § 11), and, if your privacy obligations require erasure, the whole procedure of section 8 wired, not ConversationStore::erase alone: every identity (Inbox::identities), Inbox::erase_all on each of the merchant's numbers, your own copies, outbox rows and dead letters, Meta's contact book (PhoneNumber::delete_contact_book_entry for each BSUID, the others skipped with UserId::is_bsuid), an erasure journal, and a second erasure after 7 days (cms-inbox.md).
  • OPEN_QUESTIONS.md read: its defaults (OTP issue limit, PIN policy, no token refresh) were decided on 2026-09-26, and some decisions are not built yet (Redis TLS). Dead letters for webhook events a sink refuses permanently are built (L21a) but off until you give the handler a store (webhooks.md § 9). The OTP namespace is required since d67b3ac; a revoked message keeps its content in the inbox (decided on 2026-09-25).
  • Upgrading from an older meta-whatsapp-rs revision, per commit crossed:
    • e40b86f: outstanding OTP codes become NotFound once (their store keys now include the sending number), and issue limits restart (otp-login.md).

    • 4b47bf7: a custom ConversationStore's update_status takes the business phone_number_id first.

    • 6d50701 and a9593f3: a custom ConversationStore must implement three new methods, append_synced, fill_media_placeholder and revoke, and pass conversation_conformance::run (cms-inbox.md).

    • af5b1f8: that suite also fails a custom store that fills a revoked placeholder or lets a revoke's tombstone into the conversation summary.

    • 8238853: OTP codes in flight answer Invalid once (the code hash now covers its store key).

    • 8238853 and 7e4801f: OtpService::new refuses a namespace with edge whitespace, control or format characters (Error::Config); fixing it changes the store keys, so codes in flight answer NotFound once and limits restart (otp-login.md).

    • PR #7, lossless message content (the owner's decision of 2026-09-25): Postgres migration 3 rewrites the inbox tables, and an older revision cannot run against them afterwards. In this order (details and a pre-flight query: the meta_whatsapp_adapters::store::postgres docs, "Upgrading to lossless content"):

      1. Back up wa_messages and wa_conversations of every table prefix. The only way back is a restore, which loses what was recorded after the upgrade: webhooks the new instances acknowledged are not delivered again, and replies sent meanwhile leave the history.
      2. Stop every instance of the older revision that writes to them (webhook receivers, anything calling Inbox::send). That pauses every webhook consumer, OTP delivery statuses and PARTNER_REMOVED revocations too; Meta's backoff decides how long the backlog takes afterwards. An older instance left running fails on every content statement (500s Meta redelivers, a reply sent but not recorded).
      3. Drop the objects of your own on kind, text, payload, error and last_text that the pre-flight query lists. Migration 3 refuses to run under anything on payload or error (an expression such as payload->>'type' would fail every later insert of a payload holding a NUL); Postgres refuses views, rules, trigram and full-text indexes on the others; triggers and functions that name those columns are not checked and would fail every insert, so rewrite them. Plain b-tree indexes on text are kept.
      4. Run migrate once, from a one-off job, per table prefix, with a lock_timeout and no statement_timeout on its connection, and free disk for a copy of wa_messages and its indexes. It locks both tables for the rewrite: 200,006 messages (a 153 MB table) took 1 to 2 seconds on a local Postgres 18.
      5. Update SQL of your own (cms-inbox.md).
      6. Start the new revision.

      Existing rows keep their content; a NUL an older revision stored as U+FFFD stays U+FFFD. A custom ConversationStore now receives U+0000 from the inbox, which no longer replaces it: one on text or jsonb fails its webhook batches and logs replies as "message sent but not recorded" until it keeps content exactly (the conformance suites say so).

    • The revision that adds EmbeddedSignup::clear_pending_share adds an audit trail to each WABA's credit record (cleared_shares). An older revision knows nothing of it: rolling back, or running an older revision beside a newer one, drops the trail whenever the older one writes that record. From that revision on, fields a later revision adds to a credit record or an audit entry are kept. Keep your own append-only log of each returned ClearedShare too.

    • The pull request of roadmap L5 (the ConversationStore port change): a custom ConversationStore must implement fourteen new methods (message, window events, thread ownership, synced contacts, identity links, identities, erase_all, purge_before; erase is provided) and pass conversation_conformance::run, under each ErasureMode it offers. Postgres migration 4 adds four tables, a sender column on wa_messages and four indexes on the existing tables, and changes no existing column: the previous revision keeps working beside it, but its migrate then refuses the database, so upgrade every instance that migrates at startup. It back-fills the sender of every inbound message already stored (one UPDATE of those rows): on a large inbox, run it from a one-off job with a lock_timeout (reads and writes of wa_messages wait meanwhile). An instance of the previous revision takes none of the advisory locks that keep an erasure and the appends in flight consistent, and writes no sender: the next erasure on a number fills in the senders missing there (from the payloads, in Rust) before it matches, but a group message such an instance records while an erasure runs is missed. Finish the upgrade before you erase.

    • Nothing else is back-filled: rows and conversation summaries recorded before an upgrade stay as they were written (synced history recorded before 6d50701 keeps the unread count and window it moved, for instance).

    • The pull request of roadmap B4 (retention and auto-delete): a custom ConversationStore must implement one new method, trim_conversations (the cap per chat), and pass conversation_conformance::run again. No migration: the Postgres store ranks rows on the existing messages_conversation_idx and takes the existing purge lock.

  • Media conversion (feature ffmpeg, off by default): Meta converts nothing, so a recording becomes a voice message's Ogg Opus, and an image a sticker's static WebP or a JPEG or PNG under the 5 MB limit, on your side. meta_whatsapp_adapters::media::FfmpegConverter runs an ffmpeg you install on the host (built with libopus, and libwebp for stickers; the library links none, and the service's image carries none, so its licence is that of the build you choose). Point with_program at an absolute path, call check_for with the formats you convert to (check() for voice alone) at startup so a missing binary or encoder fails there (ConversionError::BinaryMissing, EncoderMissing) rather than at the first upload, and bound how many conversions run at once (each is a process on about a CPU core for up to its timeout, an image's attempts one after another; the converter queues nothing). It caps each allocation in ffmpeg (16 MiB for audio, 256 MiB for an image, which may have up to 32 Mi pixels) but not the process's total memory: give it a cgroup, or point with_program at a wrapper script that sets ulimit -v and then execs ffmpeg (exec, so that a timeout kills ffmpeg itself). The process inherits no environment but PATH; a build that needs LD_LIBRARY_PATH sets it in such a wrapper. Its input and run time are bounded (64 MiB and 60 s by default; a sticker from a large photo spends most of it on a first attempt at the photo's own size, about 18 s for 24 megapixels on 4 cores, so do not lower it for stickers), the result to its format's limit (16 MiB for voice, 100 000 bytes for a sticker, 5 000 000 for an image), and ffmpeg's own diagnostics are never logged: they name files and URLs. Its failures are Error::Conversion (retry only Timeout; none was sent to Meta). CI proves it against a real ffmpeg (docs/dev-environment.md).

8. Retention and erasure on Postgres ​

The Postgres store deletes for real: purge_before, apply_retention, trim_conversations and erase_all run DELETEs (and, for a group message under ErasureMode::Redact, an UPDATE that overwrites its content). What a deletion leaves behind is Postgres's, and yours to handle (cms-inbox.md § 8 says what the store itself does not reach):

  • Dead rows. A deleted or overwritten row stays on disk, with its index entries (BSUIDs, phone numbers, message ids), until (auto)vacuum reclaims it, and freed space keeps its bytes until it is reused. Run VACUUM on wa_messages, wa_conversations, wa_window_events, wa_thread_owners, wa_synced_contacts and wa_identity_links after erasures; when you need a guarantee that the bytes are gone, rewrite them with VACUUM FULL (an ACCESS EXCLUSIVE lock: the inbox waits) or pg_repack (online).
  • Copies. Backups, WAL archives (point-in-time recovery), streaming and logical replicas, and change-data-capture or ETL consumers of these tables keep what was deleted until their own retention drops it: that retention bounds how long an erased customer survives. A CDC consumer must apply the deletes, and a redaction's updates, downstream.
  • Restores bring erased customers back. Keep an erasure journal outside the database you restore: per erasure, the time and an HMAC, under a key of your own from your secret manager, of phone_number_id|contact for each identity erased: it names nobody in clear. After any restore, and before the inbox serves again, replay it: compute the same HMAC over every identity the restored tables hold (the business number with each conversation's and synced contact's contact, each link's two identities, each message's sender) and erase_all the ones the journal lists.
  • Server logs. With log_min_duration_statement, log_statement or auto_explain on, Postgres logs a statement's bound parameters in full (message text, names, BSUIDs; a large history chunk is the likeliest slow statement). Set log_parameter_max_length = 0 for the application's role (a superuser runs ALTER ROLE <role> SET log_parameter_max_length = 0), and keep log_parameter_max_length_on_error at its default, 0.
  • Keep the dedup markers. DedupGuard keeps a hashed marker per webhook event (wa.webhook.dedup) for 7 days and an hour; while it exists, Meta's redelivery of an event already delivered is dropped instead of recording the erased customer's message again. Never purge them as part of an erasure. What arrives after the erasure is recorded as any new event is (a late revoke's tombstone, an echo, a history chunk, an address book sync), so erase again once Meta's 7-day redelivery window has passed.

The procedure, per erasure request:

  1. Collect every identity: Inbox::identities(&key) on each of the merchant's numbers, and the ones you hold yourself (the phone number the customer gave you, a BSUID in your CRM).
  2. Inbox::erase_all(&identities) on each of the merchant's numbers, behind your ownership check of the number (an Inbox is bound to one number and refuses another's keys). Their group messages are redacted and keep their ids (a wamid encodes the phone number) unless the store deletes them (ErasureMode::Delete).
  3. Delete your own copies (downloaded media, exports, SSE clients' caches), the service's outbox rows (roadmap M2f) and your dead letters for that customer: meta_whatsapp_rs::erasure::Eraser with with_dead_letters does step 2 and the dead letters together (L21a; webhooks.md § 9). A dead letter is kept 14 days by default (DeadLetters::with_max_age): list that retention with the inbox's.
  4. Delete the customer from Meta's contact book: client.phone_number(pnid).delete_contact_book_entry(&bsuid) for each of their BSUIDs, with the merchant's own client (with_token) on one of their numbers. The book is the business portfolio's: one number of the portfolio the BSUID belongs to will do, and the entry goes for every number of that portfolio, unlike steps 1 and 2, which stay on each number. Use the identities step 1 collected: step 2 removed the contacts and links that connect them, so Inbox::identities now returns only the key it is given, and a loop over that misses the customer's other BSUIDs (every one, from a phone number's key) without an error. They mix contact keys, phone numbers, BSUIDs and parent BSUIDs, and the call refuses anything but a standard BSUID with Error::Validation (kind InvalidParameter), before any request: keep the BSUIDs with UserId::is_bsuid, skip the rest, and never abort the procedure on one of them (note a failure from Meta and try that BSUID again later; steps 5 and 6 still run). It cannot be undone, and it answers whether an entry existed: false when there was none, so a repeat is harmless. Meta may still show the phone number: a business number that exchanged a message or call with it in the last 30 days keeps getting it in its webhooks (the page's rule is per number), and any new interaction records the entry again.
  5. Journal the erasure (above).
  6. Erase again after 7 days (steps 1 and 2; step 4 again too if the customer interacted since, a repeat that answers false otherwise).

The dev container ​

The repository's .devcontainer/ is for working on meta-whatsapp-rs, not for deploying it: the pinned Rust toolchain, just, cargo-deny, the typst CLI, Postgres and Redis sidecars, and a default-deny egress firewall (it fails closed) that still lets graph.facebook.com through. Inside it, just ci and just test-live use the sidecars. Details: dev-environment.md. To run the examples against Meta from inside it, pass the tokens as environment variables; nothing in the container stores them.

Released under the MIT License.