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.
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:
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.
Stable name for this deployment. 1 to 256 bytes, no whitespace or control characters.
min_libxmtp_version
semantic version
unset
Lowest libxmtp version a client may use. Unset admits every version.
listen
socket address
0.0.0.0:5050
Address the plaintext gRPC listener binds.
log_level
off, error, warn, info, debug, trace
info
Global log level. The --log-level flag overrides it.
log_format
text, json
text
Log output format.
request_logger
boolean
true
Emit one INFO event when each gRPC response finishes or is cancelled.
max_drain_duration_ms
integer
10000
How 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.
Separate Prometheus listener. An empty string disables it.
otlp_endpoint
HTTP or HTTPS URL
unset
OTLP gRPC endpoint. Falls back to OTEL_EXPORTER_OTLP_ENDPOINT. No endpoint disables OTLP export.
otlp_logs
boolean
false
Export logs through OTLP when an endpoint is set.
service_name
string
xmtp-backend
Non-empty telemetry service name.
sample_ratio
number from 0 to 1
1.0
Trace sampling ratio. Backend metrics do not depend on it.
resource_attributes
map 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.
Connections per request pool. With a replica there are two pools.
max_statement_timeout_ms
integer
5000
PostgreSQL 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.
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.
Key
Type
Default
Purpose
enabled
boolean
none, required
Whether this section applies. false serves without authentication.
jwks_url
URL
unset
Fetch public signing keys over HTTPS. HTTP is allowed only on loopback hosts.
keys
array of key tables
unset
Inline 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_keys
map of name to string
unset
Named 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.
audiences
array of strings
unset
Require a matching audience when set. Must not be empty.
issuers
array of strings
unset
Require a matching issuer when set. Must not be empty.
required_scopes
array of strings
[]
Require every listed scope.
leeway_seconds
integer from 0 to 300
60
Allow this clock tolerance when validating token times.
jwks_refresh_seconds
positive integer
300
Interval between signing-key refreshes, before jitter.
jwks_max_stale_seconds
positive integer
3600
Maximum 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.
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.
Settings for handles, the public names an inbox claims. The backend publishes
them, and applies them only when it admits a new claim.
Key
Type
Default
Purpose
min_name_length
integer, 1 to 27
3
Shortest 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.
Key
Type
Purpose
component_id
integer, 0xC000–0xFEFF
The field’s ID. Unique in the table.
name
string, 1 to 100 bytes
The label apps look the field up by. Unique, and not a well-known name.
component_type
string
The value shape. See the list below.
insert_policy
string
Who may add a value.
update_policy
string
Who may change a value.
delete_policy
string
Who may remove a value.
in_groups
boolean
Register the field in new groups.
in_dms
boolean
Register 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.
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.
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.