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:
| Data | Port | Namespace | Lose it and |
|---|---|---|---|
merchants' business tokens (TokenVault) | KvStore | wa.token | every merchant reconnects |
Embedded Signup attempts (SignupSessions) | KvStore | wa.es.session | attempts in flight fail |
OTP challenges and issue limits (OtpService) | KvStore | wa.otp, wa.otp.rate | codes in flight fail; limits reset |
webhook dedup (DedupGuard) | KvStore | wa.webhook.dedup | Meta's retries are delivered again |
inbox history (InboxSink, Inbox) | ConversationStore | tables wa_* | the history |
| Memory | Postgres (postgres) | SQLite (sqlite) | Redis (redis) | |
|---|---|---|---|---|
KvStore | MemoryKvStore | PostgresKvStore | SqliteKvStore | RedisKvStore |
ConversationStore | MemoryConversationStore | PostgresConversationStore | SqliteConversationStore | RedisConversationStore |
| survives restarts, shared by instances | no | yes | survives restarts; the processes of one machine | yes, with persistence on |
| expiry clock | the process | the database server | the process | the Redis server |
| maintenance | apply_retention (or a bot RetentionSweeper: by age and a cap per chat) on a schedule, when a retention is set | migrate at startup; purge_expired() every few minutes to hourly; apply_retention (or a bot RetentionSweeper) on a schedule, when a retention is set | migrate at startup; purge_expired() now and then; apply_retention (or a bot RetentionSweeper) on a schedule, when a retention is set | eviction 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 |
| limits | one instance | refuses 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 time | a 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.
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:
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
scratchor distroless image with no CA bundle,buildfails: pass the CA).root_certificates_pemtrusts 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#insecureis 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 toRedisKvStore::newas 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
| Secret | Used for | Keep it | Rotate |
|---|---|---|---|
| system user token | your own number (OTP, order messages) | secret manager | generate a new one in Business Settings, deploy, revoke the old |
| app secret | webhook signatures, the code exchange, the app token for debug_token | secret manager | list old and new in SignatureVerifier::new while rolling out, then drop the old |
| verify token | webhook GET verification | secret manager | change it in the dashboard and in your config together |
| vault key(s) | encrypting merchants' tokens, and a Solution Partner's credit ledger | secret manager, not the vault's database | VaultKeys::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 pepper | keyed hashes of codes and numbers | secret manager, not the OTP database | invalidates outstanding codes and resets limits |
| merchants' business tokens | acting as a merchant | the vault only | merchant reconnects (no refresh) |
| two-step PINs | registering numbers | not stored by meta-whatsapp-rs; the examples ask the merchant per attempt | asked 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:
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) // RUST_LOG=info,meta_whatsapp_client=debug
.init();| Level | What |
|---|---|
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) |
error | signed 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) | Value | In meta-whatsapp-rs |
|---|---|---|
| messages per number | 80/s by default, up to 1,000; inbound messages count too | RateLimited (130429), replayed within the retry budget; a batch paced per number by the bot crate's Broadcast and Pacer (bots.md) |
| same user | about one message per 6 s, short bursts borrowed from later | PairRateLimited (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 users | portfolio messaging limit, 250 to unlimited per rolling 24 h | not tracked: count in your campaign queue |
| management endpoints | 200 requests/hour per app per WABA (5,000 for active WABAs) | don't list templates or phone numbers per request: cache them |
| number registration | 10 per 72 h | 133016 locks for 72 h, never retried |
| webhooks | answer with median ≤ 250 ms; size for ~3× outbound + 1× inbound | see 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:
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::DEFAULTis 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 byrevand 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'sFB.init. - Before moving: read Meta's changelog for the new version, rerun your integration tests against a test WABA, and watch the
Unknowncount: webhook fields Meta adds arrive asUnknowninstead of failing. - Embedded Signup v2 and v3, including their public previews, are deprecated on 2026-10-15 (the matching
EsVersionvalues are#[deprecated]);LaunchOptionstargets 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
Clientper process, cloned;with_tokenper merchant. Eachmeta_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
ChannelSinkqueue 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(plainonboardis refused), reserving the WABA for the merchant in one atomic write, andresume_with_approvalfor tokens stored before the deployment became a Solution Partner; PartnerRemovedwired torevoke_credit_line(torevoke_business_credit_linewhen it names no WABA) at once, a coexistence disconnection included (the owner's decision, 2026-09-25; a merchant who reconnects onboards again withreshare_after_revocation), ignoring one whosewaba_info.solution_partner_business_idsdoes not list your business (a Multi-Partner Solution you are not in);PartnerAppUninstalledwired tooffboard, only when itswaba_info.partner_app_idis 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::Reconcileand on aCreditError::RevocationIncompletethat is not retryable: both need a person and Meta Business Suite (both areErrorKind::Unknown); a retryable one (Meta has not confirmed aDELETE, 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_sharewith 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_retentionon the conversation store and a scheduledapply_retention, or aRetentionPolicywith an age and a cap per chat run by aRetentionSweeper, bots.md § 11), and, if your privacy obligations require erasure, the whole procedure of section 8 wired, notConversationStore::erasealone: every identity (Inbox::identities),Inbox::erase_allon each of the merchant's numbers, your own copies, outbox rows and dead letters, Meta's contact book (PhoneNumber::delete_contact_book_entryfor each BSUID, the others skipped withUserId::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
NotFoundonce (their store keys now include the sending number), and issue limits restart (otp-login.md).4b47bf7: a custom
ConversationStore'supdate_statustakes the businessphone_number_idfirst.6d50701 and a9593f3: a custom
ConversationStoremust implement three new methods,append_synced,fill_media_placeholderandrevoke, and passconversation_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
Invalidonce (the code hash now covers its store key).8238853 and 7e4801f:
OtpService::newrefuses a namespace with edge whitespace, control or format characters (Error::Config); fixing it changes the store keys, so codes in flight answerNotFoundonce 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::postgresdocs, "Upgrading to lossless content"):- Back up
wa_messagesandwa_conversationsof 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. - 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 andPARTNER_REMOVEDrevocations 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). - Drop the objects of your own on
kind,text,payload,errorandlast_textthat the pre-flight query lists. Migration 3 refuses to run under anything onpayloadorerror(an expression such aspayload->>'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. - Run
migrateonce, from a one-off job, per table prefix, with alock_timeoutand nostatement_timeouton its connection, and free disk for a copy ofwa_messagesand 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. - Update SQL of your own (cms-inbox.md).
- Start the new revision.
Existing rows keep their content; a NUL an older revision stored as U+FFFD stays U+FFFD. A custom
ConversationStorenow receives U+0000 from the inbox, which no longer replaces it: one ontextorjsonbfails its webhook batches and logs replies as "message sent but not recorded" until it keeps content exactly (the conformance suites say so).- Back up
The revision that adds
EmbeddedSignup::clear_pending_shareadds 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 returnedClearedSharetoo.The pull request of roadmap L5 (the
ConversationStoreport change): a customConversationStoremust implement fourteen new methods (message, window events, thread ownership, synced contacts, identity links,identities,erase_all,purge_before;eraseis provided) and passconversation_conformance::run, under eachErasureModeit offers. Postgres migration 4 adds four tables, asendercolumn onwa_messagesand four indexes on the existing tables, and changes no existing column: the previous revision keeps working beside it, but itsmigratethen refuses the database, so upgrade every instance that migrates at startup. It back-fills the sender of every inbound message already stored (oneUPDATEof those rows): on a large inbox, run it from a one-off job with alock_timeout(reads and writes ofwa_messageswait 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
ConversationStoremust implement one new method,trim_conversations(the cap per chat), and passconversation_conformance::runagain. No migration: the Postgres store ranks rows on the existingmessages_conversation_idxand 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::FfmpegConverterruns anffmpegyou 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). Pointwith_programat an absolute path, callcheck_forwith 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 inffmpeg(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 pointwith_programat a wrapper script that setsulimit -vand thenexecsffmpeg(exec, so that a timeout killsffmpegitself). The process inherits no environment butPATH; a build that needsLD_LIBRARY_PATHsets 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), andffmpeg's own diagnostics are never logged: they name files and URLs. Its failures areError::Conversion(retry onlyTimeout; none was sent to Meta). CI proves it against a realffmpeg(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
VACUUMonwa_messages,wa_conversations,wa_window_events,wa_thread_owners,wa_synced_contactsandwa_identity_linksafter erasures; when you need a guarantee that the bytes are gone, rewrite them withVACUUM FULL(anACCESS EXCLUSIVElock: the inbox waits) orpg_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|contactfor 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'scontact, each link's two identities, each message'ssender) anderase_allthe ones the journal lists. - Server logs. With
log_min_duration_statement,log_statementorauto_explainon, Postgres logs a statement's bound parameters in full (message text, names, BSUIDs; a large history chunk is the likeliest slow statement). Setlog_parameter_max_length = 0for the application's role (a superuser runsALTER ROLE <role> SET log_parameter_max_length = 0), and keeplog_parameter_max_length_on_errorat its default, 0. - Keep the dedup markers.
DedupGuardkeeps 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:
- 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). Inbox::erase_all(&identities)on each of the merchant's numbers, behind your ownership check of the number (anInboxis bound to one number and refuses another's keys). Their group messages are redacted and keep their ids (awamidencodes the phone number) unless the store deletes them (ErasureMode::Delete).- 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::Eraserwithwith_dead_lettersdoes 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. - 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, soInbox::identitiesnow 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 withError::Validation(kindInvalidParameter), before any request: keep the BSUIDs withUserId::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:falsewhen 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. - Journal the erasure (above).
- Erase again after 7 days (steps 1 and 2; step 4 again too if the customer interacted since, a repeat that answers
falseotherwise).
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.