Skip to content

Run the backend

The XMTP backend is one binary. It serves native gRPC, gRPC-Web, and the standard gRPC health service on a single port. It keeps no durable local state, so you can run several instances behind a load balancer and scale them horizontally. All durable state lives in PostgreSQL.

A client reconnecting to a different instance does not need anything from the instance it left. It reconnects with its own topic cursors.

  • PostgreSQL 17 or later.
  • One TOML configuration document.
  • A TCP port for the gRPC listener. The default is 5050.

The backend applies pending schema migrations at startup. Startup never deletes a database.

Pass inline TOML with --config or XMTP_CONFIG. For a mounted file, use --config-file instead. Supply exactly one source. server.identifier and database.url are required. Other sections have defaults or their own required fields. Unknown keys, invalid values, and inconsistent size relationships fail startup.

A string of the form env:NAME reads the environment variable NAME at startup. A missing variable fails startup. Keep secrets in environment variables. Resolved values never appear in errors or logs.

A minimal file needs the deployment identifier and the database URL:

#:schema https://raw.githubusercontent.com/xmtp/libxmtp/main/docs/schemas/backend-v1.json
[server]
# Change this to a name you own.
identifier = "org.example.xmtp"
[database]
url = "env:XMTP_DATABASE_URL"

The JSON schema at that URL validates the file in editors that support Taplo.

To offer remote ciphertext uploads, add [attachments] and an S3 target. See Attachment storage for the target contract, retention rule, CORS headers, and credential kinds. Without this section, the published configuration has no attachment offer and CreateUpload returns UNIMPLEMENTED.

KeyTypeDefaultPurpose
identifierstringnone, requiredStable name for this deployment. 1 to 256 bytes, no whitespace or control characters.
min_libxmtp_versionsemantic versionunsetLowest libxmtp version a client may use. Unset admits every version.
listensocket address0.0.0.0:5050Address the plaintext gRPC listener binds.
log_leveloff, error, warn, info, debug, traceinfoGlobal log level. The --log-level flag overrides it.
log_formattext, jsontextLog output format.
request_loggerbooleantrueEmit one INFO event when each gRPC response finishes or is cancelled.
max_drain_duration_msinteger10000How long shutdown waits for in-flight unary requests. Open streams fail at once.

The convention for identifier is a reverse-DNS name you control, such as org.example.xmtp. Change the example to your own name before you deploy.

min_libxmtp_version is compared on major, minor, and patch only, so a prerelease tag does not change the verdict. A client below the minimum refuses to build, and a running client that learns of a raised minimum stops.

Both are client-side checks, made by a client that reads this deployment’s published configuration. The backend only publishes the minimum: it does not reject requests by version. A connected client must refresh the published configuration before it can detect a raised minimum.

KeyTypeDefaultPurpose
metrics_listensocket address or empty string0.0.0.0:9464Separate Prometheus listener. An empty string disables it.
otlp_endpointHTTP or HTTPS URLunsetOTLP gRPC endpoint. Falls back to OTEL_EXPORTER_OTLP_ENDPOINT. No endpoint disables OTLP export.
otlp_logsbooleanfalseExport logs through OTLP when an endpoint is set.
service_namestringxmtp-backendNon-empty telemetry service name.
sample_rationumber from 0 to 11.0Trace sampling ratio. Backend metrics do not depend on it.
resource_attributesmap of strings{}Extra resource attributes. service.name, service.version, and xmtp.backend.identifier are reserved.

Keep the metrics listener private. Resource attributes are exported as supplied. Never put secrets in them. The backend exports server.identifier as the xmtp.backend.identifier resource attribute and includes it on every request completion log and every authentication rejection log, so one log stream can carry several deployments.

KeyTypeDefaultPurpose
urlPostgreSQL URLnone, requiredPrimary connection string.
replica_urlPostgreSQL URLunsetOne physical read replica. Reads route to it.
max_connectionsinteger20Connections per request pool. With a replica there are two pools.
max_statement_timeout_msinteger5000PostgreSQL statement_timeout applied to every statement.

Configure at most one physical read replica. Do not point replica_url at a load balancer that selects independently lagging replicas.

Publish and Query use the primary. Newest, identifier lookup, and subscriptions use the selected read database, so those reads can lag behind a successful publish.

The tailer holds one dedicated connection to the read database, beyond the request pools. Without a replica, budget max_connections + 1 connections per instance. With a replica, budget max_connections on the primary and max_connections + 1 on the replica. When push is enabled, also budget one dedicated primary connection for the push dispatcher. See the connection budget for multi-instance deployments.

KeyTypeDefaultPurpose
max_publish_duration_msinteger10000Ceiling on one publish request, from first lock to commit. Must be greater than database.max_statement_timeout_ms.
max_barrier_wait_msinteger1000How long the boundary task waits for the allocation barrier before it retries later.
KeyTypeDefaultPurpose
poll_interval_msinteger100Interval between tailer polls of the read database. Polls do not overlap.
max_gap_rangesinteger10000Unresolved gap ranges the tailer keeps before it fails affected streams.
keepalive_interval_msinteger30000Send-idle time before the server sends a Ping.
max_pong_wait_msinteger90000How long the server waits for the matching Pong. Must be greater than keepalive_interval_ms.
KeyTypeDefaultPurpose
group_message_secondspositive integer7776000Expiry period for group application messages.
welcome_secondspositive integer7776000Expiry period for welcomes.
key_package_secondspositive integer7776000Expiry period for key packages.

Each default is 90 days. Identity updates, commit-log entries, and group commits and proposals do not expire. Startup checks that each period fits the stored timestamp type against the primary database clock. Expiry is currently metadata: rows are not pruned or hidden at read time.

Omit this section to disable caller authentication. When the section is present it must say enabled = true or enabled = false; a section without it fails startup, so authentication can never switch off by accident. To enable caller authentication, set enabled = true and at least one mechanism: api_keys, jwks_url, or a non-empty keys array. Do not set both jwks_url and keys. An otherwise empty enabled [auth] section fails startup. Requests then require a valid bearer token, except for gRPC health, ConfigurationService, and CORS preflight. Streams are checked when they open.

With enabled = false the backend serves every RPC without a credential check, validates no other key in the section, loads no keys, and fetches no JWKS. Environment references are resolved for the whole document before any section is validated, so an env: value naming a variable that is not set still fails startup, even in a disabled section.

KeyTypeDefaultPurpose
enabledbooleannone, requiredWhether this section applies. false serves without authentication.
jwks_urlURLunsetFetch public signing keys over HTTPS. HTTP is allowed only on loopback hosts.
keysarray of key tablesunsetInline public signing keys. Each key needs a unique, non-empty kid of at most 256 bytes, an alg, and a PEM SubjectPublicKeyInfo public_key.
api_keysmap of name to stringunsetNamed static API keys. Names must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$. Values must be 32 to 8192 bytes of printable ASCII without whitespace. Values must be unique across names. Set at most 256 keys.
audiencesarray of stringsunsetRequire a matching audience when set. Must not be empty.
issuersarray of stringsunsetRequire a matching issuer when set. Must not be empty.
required_scopesarray of strings[]Require every listed scope.
leeway_secondsinteger from 0 to 30060Allow this clock tolerance when validating token times.
jwks_refresh_secondspositive integer300Interval between signing-key refreshes, before jitter.
jwks_max_stale_secondspositive integer3600Maximum time since a successful key fetch before the backend drains and exits.

Supported key algorithms are RS256, RS384, RS512, ES256, ES384, and EdDSA. The stale-key budget must cover the refresh interval, up to 10% jitter rounded up to whole seconds, and a 10 s fetch timeout.

For example, supply your key-set URL and expected token claims:

[auth]
enabled = true
jwks_url = "env:XMTP_JWKS_URL"
audiences = ["xmtp"]
issuers = ["https://auth.example.com"]
required_scopes = ["xmtp:access"]

To use only API keys, set a name for each secret value:

[auth]
enabled = true
[auth.api_keys]
worker = "env:XMTP_WORKER_API_KEY"

To accept API keys and JWTs, set both mechanisms:

[auth]
enabled = true
jwks_url = "env:XMTP_JWKS_URL"
audiences = ["xmtp"]
issuers = ["https://auth.example.com"]
required_scopes = ["xmtp:access"]
[auth.api_keys]
worker = "env:XMTP_WORKER_API_KEY"

The backend checks API keys before JWTs. An API key match uses the key name as the principal. Scope, audience, and issuer checks apply only to JWTs.

Caller quotas are not implemented. A valid token does not prove group membership.

A map from EIP-155 CAIP-2 chain ID to an HTTP or HTTPS RPC URL. Configure one route per chain you need for smart-contract-wallet signature verification. An empty map still serves identities that do not need chain verification; smart-contract-wallet operations on an unconfigured chain return UNAVAILABLE.

[chains]
"eip155:1" = "env:XMTP_RPC_MAINNET"
KeyTypeDefaultPurpose
max_scw_cache_entriesinteger10000Entries in the per-instance smart-contract-wallet signature-verdict cache. LRU eviction.

Every structural limit is one named key. See Limits for the values and what each one constrains. Lowering a limit here can require clients to send smaller batches. Startup rejects inconsistent pairs: max_query_limit must be at least default_query_limit, the envelope plus protocol overhead must fit the request and response byte limits, and max_update_adds at most max_stream_topics. max_request_bytes and max_response_bytes must each stay at or below the fixed 25 MiB transport ceiling.

Group policy the backend publishes to clients. The backend does not enforce these values; clients read them and apply them locally.

KeyTypeDefaultPurpose
max_group_membersinteger, 1 to 65535250Largest group a client will create or grow. Advisory.
max_installations_per_inboxinteger, 1 to 6553510Installations a client keeps for one inbox. Advisory.
commit_log_enabledbooleantrueWhether clients write and read the commit log on this deployment.

Settings for handles, the public names an inbox claims. The backend publishes them, and applies them only when it admits a new claim.

KeyTypeDefaultPurpose
min_name_lengthinteger, 1 to 273Shortest name part, the text before #, of a new claim. Handles claimed before a change stay valid.

The other claim limits, such as 64 claims per inbox, are fixed. See Handle limits. After the backend stores a claim, do not roll back to a build without handles. See Database and migrations.

Group metadata fields the backend publishes for clients to register in the conversations they create. The backend stores and checks no values; each conversation’s own registry decides what its members may write. Omit the table to publish none. Every key is required.

KeyTypePurpose
component_idinteger, 0xC000–0xFEFFThe field’s ID. Unique in the table.
namestring, 1 to 100 bytesThe label apps look the field up by. Unique, and not a well-known name.
component_typestringThe value shape. See the list below.
insert_policystringWho may add a value.
update_policystringWho may change a value.
delete_policystringWho may remove a value.
in_groupsbooleanRegister the field in new groups.
in_dmsbooleanRegister the field in new DMs. At least one of the two must be true.

A type is bytes, string, tls_map_bytes_bytes, tls_map_inbox_id_bytes, tls_map_inbox_id_string, tls_set_bytes, or tls_set_inbox_id. A map keyed by inbox ID holds one value per member.

A policy is allow, deny, allow_if_admin, allow_if_super_admin, or allow_if_self_or_non_member. The last lets a member write only its own key of a tls_map_inbox_id_* field, and lets anyone remove the key of an inbox that has left; it denies every other type.

[[application_components]]
component_id = 0xC000
name = "GROUP_CATEGORY"
component_type = "string"
insert_policy = "allow_if_admin"
update_policy = "allow_if_admin"
delete_policy = "allow_if_super_admin"
in_groups = true
in_dms = false
[[application_components]]
component_id = 0xC001
name = "USER_PRONOUNS"
component_type = "tls_map_inbox_id_string"
insert_policy = "allow_if_self_or_non_member"
update_policy = "allow_if_self_or_non_member"
delete_policy = "allow_if_self_or_non_member"
in_groups = true
in_dms = true

GROUP_CATEGORY is one string for the group. USER_PRONOUNS is one string per inbox in each group or DM. The name prefix does not grant permissions. The map type and the three policies control how members can change their entries. These values are encrypted conversation metadata. The backend publishes their definitions but cannot read their values.

IDs from 0xC000 through 0xFCFF are mutable. IDs from 0xFD00 through 0xFEFF are immutable: a value can be set once, then cannot be changed or removed. Registration leaves the value unset in either range. Application scalars, collection keys, and collection values are limited to 8,192 bytes; serialized collection snapshots are limited to 65,536 bytes.

See Configurable fields for field discovery, typed reads and writes, and user data examples.

Keep a published field’s ID, name, type, policies, and in_groups and in_dms stable. A conversation copies the definition when it registers the ID, so a change reaches only conversations that register it later; existing registered fields keep the old definition. A client with registry write authority can register missing eligible fields in an existing conversation when it makes a commit. Retire a field by removing it and publish a replacement under a new ID and name.

Prefix the name of a field that holds one value per inbox with USER_, as the well-known USER_DISPLAY_NAME does. This is a convention; the backend does not check it.

Port 5050 also serves xmtp.backend.v1.ConfigurationService/GetConfiguration. The call needs no credential, so a client can learn what a deployment requires before it holds one. The response is built once at startup and does not change while the process runs. It carries the identifier, the backend version, the minimum client version, a summary of [auth], the retention periods, the published limits, the [mls] policy, the [handles] settings, the configured chain IDs, the attachment offer when configured, and the [[application_components]] table sorted by component_id.

It never carries the JWKS URL, chain RPC URLs, public key material, clock leeway, refresh timing, or any database, telemetry, or listener setting. The encoded response must stay under 64 KiB; a very long chain, key, audience, issuer, scope, or application component list fails startup.

To send notifications, configure one or more delivery channels. See Push configuration for APNs, FCM, and HTTPS settings and credential setup.

Run these commands from the repository root with Nix and Docker installed:

Terminal window
dev/nix-shell 'just backend up'
dev/nix-shell 'just backend status'

The first command starts the shared stack, including PostgreSQL, the backend, Anvil, Toxiproxy, attachment storage, and telemetry services. The status command shows this checkout’s backend URL. The main checkout uses http://127.0.0.1:5050; worktrees use separate ports.

To run only the backend outside Docker against the shared databases:

Terminal window
dev/nix-shell 'just backend db-up'
dev/nix-shell 'just backend run'

Start Anvil separately if you need smart-contract-wallet verification with the local configuration.

Use the recommended image ghcr.io/xmtp/backend:8.0.0-rc1. Mount a configuration file and pass --config-file. The entry point is the backend binary, so the container command is only its arguments.

Terminal window
docker run --rm -p 127.0.0.1:5050:5050 \
-e XMTP_DATABASE_URL=postgres://user:password@host:5432/xmtp_backend \
-v "$PWD/config.toml:/config.toml:ro" \
ghcr.io/xmtp/backend:8.0.0-rc1 --config-file /config.toml

Port 5050 serves native gRPC, gRPC-Web, and the standard gRPC health service. The separate Prometheus listener defaults to port 9464; see [telemetry]. Keep it private. There is no HTTP health path.

Probe health with a gRPC health client against the listener, for example grpc-health-probe -addr=127.0.0.1:5050. The backend reports both the aggregate health status and a named status for each RPC service it serves. On shutdown it marks the aggregate status and every named service NOT_SERVING before connections drain, so a load balancer can remove the instance before requests fail.

The backend listener is plaintext and is not a public endpoint. Terminate HTTPS at a trusted load balancer and pass requests to the backend without protocol conversion. Browser clients speak gRPC-Web to the same port, and that only works if the load balancer leaves the traffic alone.

The load balancer must:

  • Pass gRPC-Web requests through without converting them to or from native gRPC.
  • Stream responses through without buffering. Frames must reach the client as the backend emits them.
  • Pass CORS preflight requests to the backend. The backend answers them itself.
  • Preserve request headers, including authorization and version headers.
  • Preserve gRPC trailers and status details, including grpc-status, grpc-message, and grpc-status-details-bin.

Buffering a streaming response is the failure that is hardest to notice: unary calls keep working and only subscriptions break.

Optional API key and JWT authentication is available. Configure [auth] to require valid bearer credentials. Caller quotas are not implemented. A valid token does not prove group membership.

Without [auth], the service is unauthenticated. Do not expose an unauthenticated service to untrusted traffic. Put it on a private network, or behind a trusted load balancer that restricts access. Public application traffic must use TLS. See the deployment overview for shared deployment requirements.

Pass the key value through the client auth callback. Add Bearer and one space before the key value. The middleware does not add this prefix. The header name defaults to authorization when name is omitted.

This JavaScript example uses the public Node SDK:

import {
class Backend
Backend
} from "@xmtp/node-sdk";
const
const backend: Backend
backend
= await
class Backend
Backend
.
Backend.connect(options: BackendOptions): Promise<Backend>
connect
({
url: string
url
: "https://backend.example.com",
credentials?: CredentialSource | Credential | undefined
credentials
: {
async
CredentialSource.credential(): Promise<Credential>
credential
() {
return {
value: string
value
: "Bearer <key>",
expiresAtSeconds: bigint
expiresAtSeconds
: 4102444800n,
};
},
},
});

Replace <key> with your secret value. The example expiry is January 1, 2100. The expiry must be a signed 64-bit bigint. It controls the client credential cache, not the lifetime of the server key. Pass this backend to Client.create(signer, { backend, storage: { location: "default" } }). Swift and Kotlin use BackendOptions and a CredentialSource callback. See Backend authentication for examples.

Never log key values. To rotate a key, add the new key under a new name and restart the backend. Change clients to use the new key. Remove the old key and restart the backend again.