Skip to content

Remote attachments

A remote attachment is a file that a message carries by reference. The sender encrypts the file under CTYPE-015, stores the ciphertext at a URL, and sends a remote attachment content type that holds the URL, the key material, and the digest of the ciphertext. A recipient fetches the URL, checks the digest, and decrypts.

This spec makes storage an optional service of the backend. A backend whose operator configures a storage target publishes a base URL, and the download URL of an object is the base URL followed by the digest of its ciphertext. A client can therefore build the complete remote attachment before any byte is uploaded, and an app can send the message optimistically while the upload runs. The backend does not carry the bytes. It signs a request that lets the client put exactly those bytes, once, into the operator’s object storage. A backend with no storage target publishes no attachments configuration and answers UNIMPLEMENTED to the upload method, and an app reads which case applies from the client’s configuration snapshot.

The client keeps a plaintext copy of every attachment it creates or downloads in an attachments directory, at a path it derives from the remote attachment alone, and keeps a record of each one. An app finds the file for a message without a lookup, and every client version finds the same file.

In scope: what the backend publishes about attachment storage; the download URL of an object; the upload request and what its signature binds; the pending attachment and its status; the attachments directory, the attachment key, the local file name, and the records of pending and local attachments; downloads, the address and size rules they follow, and their verification; the failure causes an SDK reports.

Out of scope: the remote attachment encodings and the encryption of the payload (CTYPE section 7, CTYPE-014, CTYPE-015); the event kinds and their delivery (EVENT); credentials (AUTH); the operator’s configuration file and its keys, except the storage signing credential source that ATCH-073 names and the key prefix form that ATCH-075 names; the storage target’s own lifecycle, access, and cache configuration; how an SDK names its methods.

RelatedRelation
CTYPEOwns the remote attachment and multiple remote attachment encodings and their parameters (CTYPE-014), and the payload encryption and digest (CTYPE-015). This spec produces and consumes them.
CONFOwns the public configuration message, the snapshot (CONF-020), absent values and their defaults (CONF-025), the blocked connection (CONF-075), and the app’s view of the snapshot (CONF-061). GetConfigurationResponse.attachments carries the AttachmentsConfiguration this spec owns.
AUTHOwns admission and client credential failures. CreateUpload is not a public method, so AUTH-001 applies to it.
API section 7Owns the status codes and the client’s retry rule (API-284). The admission table of section 3 adds the conditions of CreateUpload.
EVENTOwns the attachment.* kinds, their payloads, and their delivery. This spec owns the transitions they report.
OPSOwns the route table and the metric catalogue, including the row for CreateUpload.
STOREOwns the default location, the storage label, and the database file name in a data directory. An SDK maps the default location and a directory the app names onto the layouts of section 5 (STORE-020).
TermMeaning
CiphertextThe bytes stored at a download URL: the CTYPE-015 encryption of an xmtp.org/attachment:1.0 encoded content, with its tag.
Content digestThe lowercase hexadecimal SHA-256 of a ciphertext, as CTYPE-015 defines it. On the wire of CreateUpload, the same 32 bytes unencoded.
Storage targetThe object storage the operator configures. It accepts the signed requests the backend issues and serves the objects at their download URLs.
Object keyThe name under which the storage target stores the ciphertext of one content digest: the operator’s configured key prefix, which may be empty, followed by the lowercase hexadecimal content digest. The operator maps the base URL to that prefix, so the download URL of ATCH-010 serves the object.
Base URLAttachmentsConfiguration.base_url.
Download URLThe URL ATCH-010 derives from a base URL and a content digest.
OfferedA snapshot offers attachments when it carries an AttachmentsConfiguration to which ATCH-008 does not apply.
Pending attachmentThe client’s database record of one ciphertext that it created for upload. It holds the status (waiting, uploading, complete, or failed), the cause of the last failed upload, and the lease of a running upload.
Upload leaseThe time until which a running upload holds a pending attachment. While the lease has not expired, no other upload of that pending attachment starts (ATCH-074).
UploadOne run of CreateUpload and the signed request for a pending attachment, from the moment its status becomes uploading until it becomes complete or failed.
Staged ciphertextThe copy of a pending attachment’s ciphertext that the client keeps from its creation until its upload completes.
Attachments directoryThe directory under which the client stores plaintext files and staged ciphertext (ATCH-040, ATCH-049).
Attachment keyThe value ATCH-041 derives from a remote attachment. It names the plaintext file’s directory.
Plaintext pathThe path ATCH-043 derives from a remote attachment.
Plaintext fileThe file at a plaintext path.
Local attachmentA plaintext file the client wrote by a creation or a download that succeeded, and has not deleted.
Download hostThe host a download URL names, or a host a redirect names. It need not be a backend’s storage target.

Attachment storage is optional. The backend offers it only when the operator configures a storage target, and it then publishes where objects are served, how large one may be, and how long the operator keeps them. A client builds every download URL from this message, so a wrong base URL sends every recipient to the wrong place, and a published ceiling the backend does not enforce lets a client build an attachment it can never upload.

A client that receives an unusable attachments message still works for messaging. It behaves as if the deployment offered no storage. Downloads do not depend on the offer: a client downloads from any host whether or not its own backend offers storage (ATCH-050).

// Published as GetConfigurationResponse.attachments (CONF section 2.1).
// Absent when the deployment offers no attachment storage.
message AttachmentsConfiguration {
// Absolute URL with no query, fragment, or trailing "/".
string base_url = 1;
// Largest ciphertext CreateUpload accepts, in bytes.
uint64 max_upload_bytes = 2;
// Seconds the operator keeps an object after it is stored. 0: no expiry.
uint64 retention_seconds = 3;
}
IDTitleRequirementWhy
ATCH-001Offered exactly when configuredThe backend MUST set GetConfigurationResponse.attachments if and only if a storage target is configured.A client that sees no message never builds an attachment; one that sees a message for a target that does not exist builds attachments nobody can upload.
ATCH-002Base URL formWhen the configured base URL is not an absolute URL under RFC 3986 §4.3 whose scheme is exactly lowercase https, or exactly lowercase http with a host that is localhost or a loopback address, or when its host is not a non-empty reg-name with no percent-encoded octet, an IPv4address, or an IPv6address (IPvFuture is not allowed), its port when present is 0 or greater than 65535, or it carries userinfo, a . or .. path segment (including percent-encoded forms), a query, a fragment, or a trailing /, the backend MUST refuse to start and name the key under CONF-065.Every recipient would derive a download URL that a content type decoder may reject under ATCH-011, that a client may not fetch under ATCH-053, that discloses a credential, or that has a doubled separator.
ATCH-003Upload ceilingWhen the configured upload ceiling is 0 or greater than the largest contentLength the CTYPE-014 parameters table admits, the backend MUST refuse to start. The backend MUST set max_upload_bytes to the ceiling it enforces under ATCH-021, which is the default the CONF defaults table gives for attachments.max_upload_bytes when the operator sets none.A larger ceiling admits an attachment whose length no remote attachment can carry.
ATCH-004Published retentionWhen the configured retention exceeds 9007199254740991 seconds, the backend MUST refuse to start and name the key under CONF-065. Otherwise, the backend MUST set retention_seconds to the configured retention, and to 0 when none is configured.A larger value loses precision in clients that represent seconds as an IEEE 754 integer.
ATCH-005Retention matches the targetAn operator SHOULD configure a retention equal to the expiry rule of the storage target, and none when the target keeps objects without expiry.An app that shows an attachment as available after its object expired shows a file no recipient can fetch.
ATCH-006Storage secrets stay privateThe backend MUST NOT place in any field of GetConfigurationResponse a storage target credential, a bucket name, or a storage endpoint URL other than the base URL.The message is served without a credential (CONF-010).
ATCH-007The target enforces the signed requestAn operator SHOULD configure only a storage target that rejects a PUT whose body’s SHA-256 or length differs from the value its signature binds, and a PUT whose signature binds the create-only precondition when an object is at the key; that answers a PUT with a 2xx status only once it serves the object unchanged at the download URL of its content digest, and then keeps serving it there; that, where browser clients use the deployment, allows a cross-origin PUT with the signed headers and a cross-origin GET; and, for an S3 target, that accepts path-style requests, {endpoint}/{bucket}/{object key}, which is the form the backend signs.ATCH-023 holds only on a target that enforces what the backend signs. On any other target, a member who knows a digest can store other bytes at it, and a target that answers before it serves the object lets the client delete its only staged copy under ATCH-037.
ATCH-073Signing credential sourcesThe backend MUST obtain the credentials it signs with from the source that attachments.target.S3.credentials.kind names: static, default_chain, environment, profile, sso, process, web_identity, container, instance, or assume_role, as the AWS SDK credential providers define them. When the kind is not one of these, or a field that kind requires is missing or empty, the backend MUST refuse to start and name the key under CONF-065.An operator who runs the backend in a cloud account must not have to copy a long-lived secret into the configuration file.
ATCH-075Key prefix formWhen the configured key prefix is not empty and contains a character other than an ASCII letter, digit, _, -, ., or /, begins with /, contains //, or has a . or .. segment, the backend MUST refuse to start and name the key under CONF-065.A prefix with other characters, or with empty or dot segments, signs an object key that differs from the one the operator maps the base URL to.
ATCH-081S3 target namesWhen the configured S3 region is empty or contains a whitespace character, or the configured bucket is empty, contains /, or is . or .., the backend MUST refuse to start and name the key under CONF-065.A request signed for an empty region, or for a bucket that is not one path segment, fails at every target, so the backend would start but every upload would fail.
ATCH-083Presign lifetime settingWhen attachments.target.S3.presign_ttl_seconds is set to a value below 300 or above 3600, the backend MUST refuse to start and name the key under CONF-065.A lifetime outside that range cannot satisfy ATCH-028, so every upload response would be wrong.
ATCH-008An unusable offer is no offerIf the snapshot carries an AttachmentsConfiguration whose base_url fails the form of ATCH-002, whose max_upload_bytes is greater than the largest contentLength the CTYPE-014 parameters table admits, or whose retention_seconds exceeds the bound in ATCH-004, then the client MUST apply this spec as if the snapshot carried no AttachmentsConfiguration.A client that builds URLs from a malformed base URL sends every recipient to nothing; one that fails creation over it loses messaging for an attachments fault (CONF-027).
ATCH-009Apps read the offerAn SDK MUST let an app read, from the client’s snapshot and without a request, whether the snapshot offers attachments.An app that cannot tell hides no attach button, and its users meet not_offered after choosing a file.

2. The download URL and the remote attachment

Section titled “2. The download URL and the remote attachment”

The download URL is a function of the base URL and the content digest. The client builds it when it creates the pending attachment, and the backend signs a request for the object key of the same digest. The same ciphertext therefore has one URL at one deployment. Two creations of one file have two URLs, because each has a fresh secret, salt, and nonce under CTYPE-015.

IDTitleRequirementWhy
ATCH-010Download URL derivationThe client MUST set the download URL of a ciphertext to the snapshot’s base_url, followed by /, followed by its content digest.A client that builds another URL sends recipients to an object that does not exist.
ATCH-011The remote attachment the client buildsWhen the client creates a pending attachment, it MUST build a remote attachment whose url is the download URL under ATCH-010, whose content_digest, secret, salt, and nonce are those of CTYPE-015 for the ciphertext, whose content_length is the ciphertext length, and whose scheme is the download URL’s scheme followed by ://. It MUST set filename to the AttachmentSource filename, or, when that is absent and the source is a path, to the final component of the path, and MUST leave it absent otherwise.A recipient bounds its download by content_length (ATCH-056) and names the local file by filename (ATCH-042).
ATCH-012The encrypted contentThe client MUST encrypt, for a pending attachment, the xmtp.org/attachment:1.0 encoded content whose content is the bytes of the AttachmentSource, whose mimeType is its mime_type, and whose filename is the remote attachment’s filename, absent when that is absent.A recipient that decodes another type fails under ATCH-051, and one that reads another MIME type opens the file with the wrong app.

The client asks the backend for permission to store one ciphertext, named by its digest and length. CreateUpload is the one method of xmtp.backend.v1.AttachmentService: a unary call that takes a CreateUploadRequest and answers a CreateUploadResponse. The backend answers with a request the client sends unchanged to the storage target. The signature binds the method, the object key, the length, the SHA-256 of the body, and a create-only precondition, so the target accepts exactly the declared bytes, and only when no object is at the key.

A second upload of stored bytes is not an error. The backend does not look for the object before it signs. The target answers 412 when an object is already at the key, and the client counts that as stored.

message CreateUploadRequest {
bytes content_digest = 1; // 32 bytes: SHA-256 of the ciphertext
uint64 content_length = 2; // ciphertext length in bytes
}
message CreateUploadResponse {
string method = 1; // "PUT"
string url = 2; // the storage target's URL for the object key
repeated HttpHeader headers = 3; // sent exactly as given
uint32 expires_in_seconds = 4; // from the response until the target refuses the request
}
message HttpHeader {
string name = 1;
string value = 2;
}

The admission table adds the conditions of CreateUpload to the status table of API section 7. The backend checks the rows in order, after admission under AUTH-001; the first row that matches decides the code. UNAVAILABLE reports a credential failure that a later request can outlive, such as a credential source that does not answer or a credential that has expired and is not yet renewed. FAILED_PRECONDITION and INTERNAL report a failure that stays until the operator corrects the configuration or the backend.

ConditionCode
No storage target is configuredUNIMPLEMENTED
content_digest is not 32 bytes, or content_length is 0 or greater than the enforced max_upload_bytesINVALID_ARGUMENT
The credential source that ATCH-073 names reports that its own configuration is invalidFAILED_PRECONDITION
The backend cannot obtain a credential from that source, or obtains one whose remaining lifetime is shorter than the least lifetime ATCH-028 allowsUNAVAILABLE
The backend cannot build or sign the request for a reason other than its credentialINTERNAL
IDTitleRequirementWhy
ATCH-020Attachment wire formatThe backend MUST publish AttachmentsConfiguration and serve xmtp.backend.v1.AttachmentService.CreateUpload as a unary RPC that takes CreateUploadRequest and returns CreateUploadResponse, with the messages of sections 1 and 3, with the field numbers and types shown, and MUST NOT reuse a field number of any of them for another meaning.
ATCH-021Upload admissionThe backend MUST fail a CreateUpload with the code the admission table names for the first row its request matches.A retryable failure reported as INVALID_ARGUMENT is dropped for good under API-284.
ATCH-023The signature binds the bytesWhen the backend answers a CreateUpload, it MUST set method to PUT and MUST sign the request so that a storage target that meets ATCH-007 accepts it only as a PUT of exactly content_length bytes whose SHA-256 is content_digest, to the object key of content_digest, while no object is at that key.A request that admits other bytes lets a member who has read the message replace the file every recipient downloads; one that admits an overwrite lets anyone erase it.
ATCH-028Signed request lifetimeThe backend MUST set expires_in_seconds to a value from 300 through 3600, equal to the lifetime of the signature it issues.A client that trusts a longer lifetime starts a PUT the target refuses.
ATCH-024The client sends the request as signedWhen the client uploads, it MUST send a PUT request to the url of the CreateUploadResponse, with every header of headers and its value, and the staged ciphertext as the body. A header that the client’s HTTP transport sets from the body, such as content-length, MUST have the value headers gives. When method is not PUT, the client MUST end the upload as failed with the cause target_rejected without sending a request to the target.The target refuses a request that differs from its signature.
ATCH-025Upload outcomeThe client MUST end an upload as complete when the target answers the signed request with a 2xx status or 412, and as failed with the cause ATCH-060 names on any other answer or transport failure, including a timeout under ATCH-070, and when CreateUpload fails or the client cannot read or write the staged ciphertext or the record.A 412 is a stored object under ATCH-023, so reporting it as a failure makes an app retry for ever.
ATCH-026Blocked clients do not uploadWhen an upload starts while the client has a blocked connection under CONF-075, the client MUST end it as failed with the cause connection_blocked before it sends any request.A client with a blocked connection is bound to another deployment or refused by this one.
ATCH-029Permanent rejections are not resentWhen an upload has ended as failed because CreateUpload answered INVALID_ARGUMENT, OUT_OF_RANGE, or UNIMPLEMENTED, the client MUST record the cause backend_rejected in the pending attachment’s record. While the record holds that cause, every client on the same database MUST end each request to upload that pending attachment as failed with that cause, without changing the record, emitting an event, or sending a request.API-284: the same request fails the same way, and a retry loop, or a restart, loads the deployment for nothing.
ATCH-027No credential off the backendThe client MUST NOT send a backend credential under AUTH section 6, or any other value that identifies the inbox or the installation, on a request to a storage target or a download host. Where the client does not open its own connections, it MUST make these requests with credentials omitted and no referrer, so the browser sends no cookie, HTTP authentication, client certificate, or Referer.The request goes to a host the backend does not control, and a download host is chosen by the sender of a message. A browser would otherwise attach the app origin’s cookies to a download URL on that origin.
ATCH-071Secure upload URLWhen the configured storage endpoint URL has neither an https scheme nor an http scheme with localhost or a loopback address, the backend MUST refuse to start and name the key under CONF-065. The client MUST NOT send an upload PUT to a URL that does not use https, except http to localhost or a loopback address, and MUST end the upload as failed with cause insecure_url. When the upload PUT receives a redirect, the client MUST NOT follow it and MUST end the upload as failed with cause target_rejected.A network attacker could fake a 2xx response to a plaintext PUT; the client would drop the staged ciphertext under ATCH-037, and every recipient would get not_found.

An app creates a pending attachment from a file path or from bytes. Creation reads the source once. It writes the plaintext file and the staged ciphertext, and returns the complete remote attachment. After that the source is not read again, so a source that moves or is deleted after creation does not affect the upload. When creation fails, it leaves nothing behind, and the app can create again.

A client holds one pending attachment for each content digest, as a record in its database. The record carries the status, the cause of the last failed upload, and the upload lease, so every client on the same database reports the same status, also after the process ends and a client is created again. An app can list the pending attachments, or ask for one by its remote attachment, and can upload it many times: after a failure, from two places or two clients at once, or after completion. A pending attachment is dropped with its record and any staged ciphertext when it reaches a maximum age, one day unless the app sets another, unless an upload of it is running. The upload table says what each request does. The attachment.upload_* events report each change of status that the client makes (EVENT section 1).

enum AttachmentSourceKind { "path", "bytes" };
dictionary AttachmentSource {
required AttachmentSourceKind kind; // exactly one of path and bytes is present
DOMString path;
sequence<octet> bytes;
DOMString filename; // ATCH-011
required DOMString mime_type; // ATCH-012
};
enum PendingAttachmentStatus { "waiting", "uploading", "complete", "failed" };

The upload table: what the client does when an app asks to upload a pending attachment, by its status at that moment, read under ATCH-074.

StatusWhat the client does
waiting, or failed with a cause other than backend_rejectedTakes the upload lease and sets the status to uploading in one change of the record, then starts an upload, which begins with CreateUpload unless ATCH-026 or ATCH-036 ends it first
failed with the cause backend_rejectedSends no request and does not change the record; the request ends as failed with the cause backend_rejected (ATCH-029)
uploadingSends no request; the request ends with the running upload’s outcome, whichever client on the database runs it
completeSends no request; the request ends successfully
IDTitleRequirementWhy
ATCH-030Creation preconditionsIf the snapshot does not offer attachments, or the ciphertext of the source would be longer than the snapshot’s max_upload_bytes, or the source’s filename and mime_type would make the encoded content’s retained fields exceed the limit of ATCH-051, then the client MUST fail creation with the cause ATCH-060 names, before it writes any file.An app that sends the remote attachment of an object that can never be stored sends a message no recipient can open.
ATCH-031The remote attachment comes firstWhen creation succeeds, the client MUST return the remote attachment of ATCH-011 without sending any request.An app sends the message optimistically, before the upload.
ATCH-032Creation reads the source onceWhen creation returns successfully, the client MUST have made the plaintext file readable at its plaintext path and written the staged ciphertext so that it survives the end of the process, and MUST NOT read the source again for that pending attachment.An app that moves or deletes its file after creation, or whose process ends after an optimistic send, would otherwise hold a message whose object can never be uploaded.
ATCH-033Failed creation leaves nothingIf creation fails, then the client MUST leave no plaintext file, no staged ciphertext, no pending attachment record, and no local attachment record for it. The client MUST write the plaintext file outside every attachment key directory and the staged ciphertext outside its final path, and put each in place only by an atomic move, so creation that the end of the process interrupts leaves no partial file at either path.An app retries creation; a leftover partial file is a file ATCH-052 would return as complete.
ATCH-072Source shapeIf an AttachmentSource whose kind is path has no path or has bytes, or one whose kind is bytes has no bytes or has path, then the SDK MUST fail creation with the cause malformed before the client reads the source or writes any file.An SDK that guesses which member to read attaches the wrong content.
ATCH-034Status transitionsThe client MUST keep a pending attachment’s status in its record: waiting at creation, then only the changes the upload table states, and the outcome of an upload under ATCH-025, ATCH-026, ATCH-029, ATCH-036, ATCH-047, or ATCH-071 when the upload ends. After complete, the status MUST NOT change.Two concurrent PUTs of one object race the create-only precondition for no gain; a completed upload repeated by a retry loop must not fail.
ATCH-074Upload leaseThe client MUST set uploading only in the same atomic change of the record that takes the upload lease, and only when no other upload holds an unexpired lease; while the upload runs, it MUST extend the lease to 120 seconds after the current time at least every 30 seconds. A record whose status is uploading and whose lease has expired holds an interrupted upload, which has no outcome: every client MUST read its status as waiting and MUST NOT emit an event for it. An upload whose lease has expired or was taken by another upload MUST NOT change the record or delete the staged ciphertext, and the request it serves MUST end as the upload table states for uploading.Two clients on one database would otherwise run two uploads of one object, a lease left by a process that ended would block the upload for ever, and a process that resumes after suspension would overwrite the state of the upload that replaced it.
ATCH-035One pending attachment per digestThe client MUST hold at most one pending attachment record for each content digest, and every handle an SDK returns for that digest, from any client on the same database, MUST report the status of that record and share its uploads.Two handles or two clients would run two uploads of one staged ciphertext, and the one that completes first deletes the file under the other (ATCH-037).
ATCH-036Unusable staged ciphertextIf an upload starts and the staged ciphertext is absent, or its SHA-256 differs from the content digest, then the client MUST end the upload as failed with the cause staged_unusable before it sends any request.A truncated file would be rejected by the target on every retry, and the app would loop on target_rejected for an object it can never store.
ATCH-037Staged ciphertext is releasedWhen a pending attachment becomes complete, the client MUST record complete and then delete its staged ciphertext.Every sent attachment would otherwise occupy its size twice on the device.
ATCH-038Resuming by remote attachmentWhen an app asks for the pending attachment of a remote attachment, the SDK MUST return the pending attachment the client holds for its content digest, with its current status, and otherwise a failure with the cause staged_unusable.An app that sent a message optimistically and was stopped before the upload completed can upload the same object, instead of sending a new message.
ATCH-066Pending attachments persistThe client MUST keep each pending attachment record, with its status and the cause of its last failed upload, across the end of the process until ATCH-047 or ATCH-068 removes it. A client created later on the same database, and any other client on it at the same time, MUST hold the pending attachment with that status, read under ATCH-074.An app restarted after an optimistic send would hold a message whose object nothing uploads, would resend an upload the backend refused (ATCH-029), and could not tell a completed upload from one that never ran.
ATCH-067Pending attachments are listedAn SDK MUST let an app list the pending attachments the client holds that are not complete, each with its remote attachment and its status.An app cannot restart uploads it cannot find.
ATCH-068Pending attachments expireWhen a pending attachment was created more than the app’s max_pending_age_seconds ago, or 86400 seconds when the app sets none, and no upload of it holds an unexpired lease (ATCH-074), the client MUST NOT list or return it, and MUST delete its staged ciphertext, if any, and its record at the next client creation. While the client runs, unless the app disabled its background workers or its attachment cleanup worker, the client MUST also delete them within 3600 seconds. The client MUST NOT delete a pending attachment’s record or staged ciphertext while an upload of it holds an unexpired lease.Staged ciphertext that nobody uploads occupies its size on the device for ever.
ATCH-039Bounded memoryWhile the client creates a pending attachment from a path, uploads it, or downloads an attachment, the memory it has allocated for that attachment’s plaintext and ciphertext MUST NOT exceed 1048576 bytes at any moment.Attachments are as large as 4 GiB; a client that holds one in memory ends a mobile app.

The client stores plaintext files in the attachments directory, one directory for each attachment key. The attachment key is derived from the fields of the remote attachment that determine the plaintext, so an app finds the file for any message from the remote attachment alone, and a remote attachment that reuses another message’s digest with other key material does not resolve to that message’s file. The file name is the sender’s filename, made safe for every file system the client runs on.

The client keeps a record of each local attachment: its path relative to the attachments directory and when the client stored it. The record holds no attachment content; the remote attachment itself is already stored with its message. The record lets an app list what the client has stored without scanning the directory. A local attachment has no expiry: it stays until the app deletes it.

An app gives the client one data directory, or, as an advanced option, a database path and an attachments directory. With a data directory, everything the client stores for an inbox on one deployment is under one directory named by the deployment and the inbox id, so an app removes an account by removing that directory, and one inbox can use two deployments from one data directory. The data directory also records which deployment answered at each backend URL, so a client created offline finds its database without a request.

enum StorageLayout { "data_dir", "explicit" };
dictionary StorageLocation {
required StorageLayout layout; // "data_dir": data_dir is present; "explicit": db_path and attachments_dir are present
DOMString data_dir;
DOMString db_path;
DOMString attachments_dir;
};
dictionary LocalAttachment {
required DOMString path; // relative to the attachments directory: {attachment key}/{local file name}
required long long created_at_ns; // when the client made the file readable, in nanoseconds since the Unix epoch
};

The local file name is the result of these steps, applied in order to the remote attachment’s filename:

StepRule
1When filename is absent, the name is attachment, and the remaining steps are skipped.
2Keep the characters after the last / or \.
3Remove every character in U+0000 to U+001F, U+007F to U+009F, U+202A to U+202E, U+2066 to U+2069, and <, >, :, ", |, ?, *.
4Remove leading . and space characters, and trailing . and space characters.
5When the part before the first . equals, ignoring ASCII case, CON, PRN, AUX, NUL, CONIN$, CONOUT$, or COM or LPT followed by one of 1 to 9, ¹, ², ³, prefix the name with _.
6While the UTF-8 encoding is longer than 255 bytes, remove the last character before the last .; when there is no ., or nothing remains before it, remove the last character.
7Apply step 4 again.
8When the name is empty, it is attachment.
IDTitleRequirementWhy
ATCH-040Data directory layoutWhen a client is created with a data_dir StorageLocation, it MUST store every file whose path it controls for the inbox under {data_dir}/{deployment}/{inbox_id}/, and MUST name a database that a platform store keeps elsewhere by the path it would have there and MUST use {data_dir}/{deployment}/{inbox_id}/attachments as the attachments directory. The deployment component MUST be the deployment identifier the database is bound to under CONF-030 after the file name table’s steps 2 to 8, with step 6 truncating to 190 bytes, and ASCII lowercasing, followed by - and the 64 lowercase hexadecimal characters of the SHA-256 of the identifier’s UTF-8 bytes; inbox_id MUST be the inbox id in lowercase hexadecimal.Two deployments’ databases for one inbox would share one path, and CONF-030 would stop the second for good; an app that removes an account’s directory would leave plaintext behind.
ATCH-069Deployment directory lookupWhen an SDK resolves the deployment identifier for the deployment component of ATCH-040 for a backend URL, it MUST use the identifier the data directory records for that URL without sending a request, and only when it records none, MUST send GetConfiguration to that URL under CONF-062 and use its identifier. When the app creates the client offline under CONF-034 and the data directory records no identifier for that URL, the SDK MUST NOT send a request and MUST fail client creation with a storage-location error; an app that must start offline before its first connection uses an explicit location. After each GetConfiguration answer that the client stores under CONF-026, CONF-033, or CONF-040 for a data directory, the SDK MUST record its identifier for that backend URL.A client created offline would not find its database, and one that fetched on every start could not start offline at all (CONF-034).
ATCH-080Explicit layoutWhen a client is created with an explicit StorageLocation, it MUST open the database at db_path and use attachments_dir as the attachments directory, without sending a request and without requiring the inbox id; ATCH-040 and ATCH-069 do not apply to it.An app that manages its own paths, or restores a cached identity, must not depend on the network or on a known inbox id to open its database.
ATCH-082Complete storage locationWhen a client is created with a StorageLocation whose layout is data_dir and that has no data_dir, or whose layout is explicit and that lacks db_path or attachments_dir, client creation MUST fail with a storage-location error before the client opens or creates any path.A client that guessed a missing path would store the database or plaintext files where the app does not expect them.
ATCH-048Browser storageWhere the client runs in a browser, it MUST resolve every path of a StorageLocation and every path this section derives as an entry of the origin private file system.
ATCH-049Staged ciphertext locationThe client MUST store every staged ciphertext under the attachments directory, outside every attachment key directory.An app that removes the attachments directory would leave ciphertext behind; one that lists a key directory would find a second file.
ATCH-077Private local filesWhere the platform and the file system support POSIX file permissions, the client MUST create every directory and file it writes under the attachments directory, including staged ciphertext and partial files, and every directory it creates for a data_dir location (ATCH-040), including the data_dir directory itself when it is missing, and the record of deployment identifiers, with access for the owning user only: mode 0700 for a directory and 0600 for a file.On a shared host, another local user would read decrypted attachments that the encrypted database does not expose.
ATCH-041Attachment keyThe client MUST set the attachment key of a remote attachment to the lowercase hexadecimal SHA-256 of the 108-byte string made of the raw bytes, not their text encodings, of its 32-byte content digest, its 32-byte secret, its 32-byte salt, and its 12-byte nonce, in that order.A key from the URL alone lets a sender who reuses another message’s URL point the recipient at that message’s file.
ATCH-042Local file nameThe client MUST derive the local file name of a remote attachment by the steps of the file name table above.A name that differs between client versions loses every file stored before the upgrade; an unsanitized name writes outside the attachments directory.
ATCH-043Plaintext path and contentThe client MUST store the plaintext file of a remote attachment at {attachments directory}/{attachment key}/{local file name}, holding exactly the content of the decrypted xmtp.org/attachment:1.0 encoded content.
ATCH-044Path without I/OAn SDK MUST let an app derive the plaintext path of a remote attachment from the client and the remote attachment alone, without a request and without reading a file.An app that renders a list of messages cannot make a call per row to find its files.
ATCH-045An existing file is not an errorWhen creation or a download finds a file at the plaintext path, the client MUST NOT fail because of it.
ATCH-046Files are the app’s to deleteThe client MUST NOT delete a plaintext file or an attachment key directory, except when an app asks it to under ATCH-047, and except a partial file of a creation or download that did not succeed.An app that shows a file the client removed shows a broken attachment.
ATCH-047Local deletionWhen an app asks the client to delete the local files of a remote attachment, the client MUST end each running upload of its content digest and each download that this client runs to a plaintext path under its attachment key directory as failed with the cause deleted, and then delete its attachment key directory, its staged ciphertext, its pending attachment record, and every local attachment record whose path is under that attachment key directory. The deletion MUST also end a download to a plaintext path under that directory that starts in this client while the deletion runs. An upload of that content digest that another client on the same database runs MUST end as failed with the cause deleted no later than its next lease extension or its outcome, when it finds the record gone.A running download that completes after the deletion puts the file back; a record left for a file the deletion removed lists an attachment the app cannot open.
ATCH-062Local attachments are listedAn SDK MUST let an app list the local attachments of a client as LocalAttachment values, with one entry for each local attachment and no other.An app that cannot list what the client stored cannot apply its own storage limits without scanning the directory.
ATCH-063Records follow the filesThe client MUST add the local attachment record right after it makes a plaintext file readable under ATCH-032 or ATCH-051, and MUST remove the record right after it deletes the file under ATCH-047.A record without a file lists an attachment the app cannot open; a file without a record is never cleaned up.
ATCH-076Crash recovery of local filesAt each client creation, before it lists local or pending attachments or answers a download, the client MUST add a local attachment record, with created_at_ns from the file’s modification time, for each plaintext file under an attachment key directory that has none; MUST remove each local attachment record whose file is missing; and MUST delete each staged ciphertext that has no pending attachment record and was last modified more than 3600 seconds ago, or more than the pending attachment age of ATCH-068 when that is shorter, and each staged ciphertext whose record is complete. It MUST NOT delete a plaintext file (ATCH-046).A process that ends between a file operation and its record leaves a file that no listing shows, or a record for a file the app cannot open; a staged file with no record is never uploaded or released.

A download takes a remote attachment from any sender and any host. The client downloads only when an app asks; it never downloads an attachment on its own. The URL is chosen by the sender, so it is untrusted. The client fetches only secure URLs, and by default only public addresses. Where it follows redirects itself, it checks each redirect as it checks the first URL. It stops at a size bound measured after content decoding. The plaintext file appears at its path only after the digest, the authentication tag, and the content type all pass, so a file at a plaintext path is always a complete, verified attachment.

A download that returns not_found can succeed later. Under optimistic sending, a recipient can ask for an object before its sender’s upload completes.

A private address is an address in an entry of the IANA IPv4 Special-Purpose Address Registry or the IANA IPv6 Special-Purpose Address Registry (RFC 6890 §2.2) whose “Globally Reachable” value is False or N/A, a multicast address, or an IPv4-mapped or NAT64 IPv6 address whose embedded IPv4 address is private.

dictionary AttachmentOptions {
unsigned long long max_download_bytes; // absent: ATCH-056
unsigned long long max_pending_age_seconds; // absent: ATCH-068
boolean allow_private_network; // absent means false
};
IDTitleRequirementWhy
ATCH-065Downloads are explicitThe client MUST NOT send a download request for a remote attachment except in a download an app asked for.A client that fetches on receipt tells every download host, which the sender chose, the recipient’s network address and when the recipient was online.
ATCH-050Any hostThe client MUST download a remote attachment whose URL passes ATCH-053 whatever its host, whether or not the snapshot offers attachments, and MUST NOT require the URL to begin with the snapshot’s base_url.Attachments sent by an app that stores its own files, or by another deployment, would not open.
ATCH-051Verified before readableThe client MUST write a download’s plaintext outside every attachment key directory and MUST put a file at the path only by an atomic move, after the ciphertext passes the digest check and the decryption of CTYPE-015 and decodes as xmtp.org/attachment:1.0 encoded content. A download that fails, or that the end of the process interrupts, MUST leave no file at the path. The client MUST NOT treat as xmtp.org/attachment:1.0 encoded content an envelope whose type field, mimeType and filename parameter entries, and compression field together take more than 65536 serialized bytes.A partial or forged file at the path is shown by every later reader as the attachment. The client holds these fields in memory while content streams to disk; no file name or MIME type approaches this size, and an unbounded one is a memory attack.
ATCH-052Existing files are not fetchedWhen a download is asked for and a file is at the plaintext path, the client MUST return that path without sending a request.
ATCH-053Secure URLs onlyThe client MUST NOT send a download request to a URL whose scheme is not https, or, where it follows redirects itself, follow a redirect to one, except http to localhost or a loopback address while allow_private_network is true.A plain-HTTP fetch shows every observer which attachment a user opens, and lets an observer replace the response.
ATCH-054Private addressesWhere the client opens its own connections, it MUST NOT connect a download request, or a redirected request, to a private address while allow_private_network is false, and MUST check the address it connects to, not the host name. Where the client opens its own connections, it MUST NOT send a download through a proxy. Where the client does not open its own connections, allow_private_network is false, and the parsed download URL host is localhost, localhost., a name ending in .localhost or .localhost., or an IP literal that is a private address as defined above, it MUST end the download as failed with cause blocked_address without sending a request.A message whose URL names the cloud metadata address or an internal host makes a server-side client fetch it. A proxy could resolve the host to an address the client did not check. A browser could fetch a private host that the sender put in the URL.
ATCH-055Redirects are checkedWhere the client follows redirects itself, it MUST follow at most 10 redirects for one download, apply ATCH-053 and ATCH-054 to each, and fail the download at the first redirect that fails either. Where the client does not follow redirects itself, it MUST NOT follow any redirect of a download and MUST end the download as failed with cause too_many_redirects on the redirect. In a browser, the client MUST set manual redirect handling on the fetch GET and MUST treat an opaque redirect as a redirect.A redirect from a public host to a blocked one would bypass both rules.
ATCH-056Download size boundThe client MUST fail a download, and stop reading, once the response body after content decoding is longer than the least of content_length when the remote attachment carries it, the app’s max_download_bytes, and the largest contentLength the CTYPE-014 parameters table admits. When the app sets no max_download_bytes, the client MUST use the snapshot’s max_upload_bytes under CONF-025, which is 104857600 when the snapshot does not offer attachments.A hostile host that streams without end, or sends a small compressed body that inflates, fills the device’s storage.
ATCH-057Success statusThe client MUST read the ciphertext only from a final response with status 200, and MUST fail a download with not_found on a final 404 or 410 and with http_status on any other final status.An app tells an object that is not yet stored, or no longer stored, from a failure of the host.
ATCH-058One fetch per pathWhile a download to a plaintext path is running in a client, the client MUST end a second download to the same plaintext path with the running download’s outcome, and MUST NOT send a request for it.Two downloads to one path race to write it and fetch the object twice.
ATCH-059Malformed key materialIf a remote attachment’s content_digest is not 64 lowercase hexadecimal characters, its secret or salt is not 32 bytes, or its nonce is not 12 bytes, then the client MUST fail a download of it and a derivation of its plaintext path with the cause malformed, without sending a request.The attachment key and the path are undefined for such a remote attachment.

An SDK reports every attachment failure with one cause from a fixed set, so an app can tell a failure it can retry from one it cannot. The same cause appears in the attachment.upload_failed and attachment.download_failed events.

enum AttachmentFailureCause {
"not_offered", // ATCH-030: the snapshot does not offer attachments
"too_large", // ATCH-030, ATCH-056
"source_unreadable", // creation could not read the path
"local_storage", // the client could not write or read the attachments directory or its records
"staged_unusable", // ATCH-036, ATCH-038
"connection_blocked", // ATCH-026
"credential", // CreateUpload failed with a client credential failure of AUTH section 6 (ATCH-061), or with PERMISSION_DENIED (ATCH-078)
"backend_rejected", // CreateUpload failed with INVALID_ARGUMENT, OUT_OF_RANGE, or UNIMPLEMENTED
"backend_unavailable", // CreateUpload failed with any other code, or did not reach the backend
"target_rejected", // the CreateUpload response names a method other than PUT, the upload PUT received a redirect, or the storage target answered a status other than 2xx or 412
"network", // ATCH-070, or a request to a storage target or download host failed in transport
"insecure_url", // ATCH-053 for a download, or ATCH-071 for an upload PUT
"blocked_address", // ATCH-054
"too_many_redirects", // ATCH-055: over 10 followed redirects or any redirect under manual handling
"not_found", // ATCH-057
"http_status", // ATCH-057
"malformed", // ATCH-059, ATCH-072
"digest_mismatch", // ATCH-051: the ciphertext does not match content_digest
"decryption_failed", // ATCH-051: the tag does not verify
"not_an_attachment", // ATCH-051: the plaintext is not xmtp.org/attachment:1.0 encoded content
"deleted" // ATCH-047
};

The cause tells an app when a later attempt can succeed:

CauseA later attempt can succeed
not_offeredAfter the deployment offers attachments
too_large, staged_unusable, backend_rejected, too_many_redirects, malformed, digest_mismatch, decryption_failed, not_an_attachmentNo. The same input fails again
source_unreadableAfter the app makes the source readable
local_storageYes, when the device has space and the directory is writable
connection_blockedAfter the client is updated or bound to its deployment again (CONF-075)
credentialAfter the app renews the credential of the kind ATCH-061 reports, or supplies one that carries the required scopes (ATCH-078)
backend_unavailableYes, after a backoff; after FAILED_PRECONDITION or INTERNAL, only when the operator has corrected the backend
networkYes, after a backoff
http_statusAfter a backoff when the status is 408, 429, or 5xx; otherwise no
target_rejectedYes, for a new upload request, which gets a new signed URL; a target that rejects every signed request fails again
blocked_addressAfter the app sets allow_private_network; otherwise no
insecure_url for a downloadOnly when the URL is http to localhost or a loopback address and the app sets allow_private_network; otherwise no
insecure_url for an uploadNo. The backend signed a URL that ATCH-071 forbids; it fails again until the operator fixes the storage endpoint
not_foundYes, while the sender has not completed the upload; no, after the target has deleted the object (ATCH-004)
deletedYes. The app can download again
IDTitleRequirementWhy
ATCH-060Typed failure causesAn SDK MUST report a failed creation, upload, download, or path derivation as an error that carries exactly one AttachmentFailureCause, the one whose comment in the block above names the failure.An app that cannot tell not_found from network retries an expired object for ever, or gives up on a transient one.
ATCH-061Credential failures keep their kindWhen an upload fails with a client credential failure of AUTH section 6, an SDK MUST report the cause credential together with the failure kind AUTH-026 names and whether it is retryable.An app that sees only an upload failure cannot tell a lockout that clears from a credential it must replace.
ATCH-078Missing scopeWhen CreateUpload answers PERMISSION_DENIED, the client MUST end the upload as failed with the cause credential, marked as a missing scope (AUTH-013) that retrying with the same credential cannot cure, and MUST NOT record it under ATCH-029.An app that reads a scope rejection as a transient outage retries for ever; one that records it as a permanent rejection cannot upload after it obtains a credential with the scope.
ATCH-079HTTP status is keptWhen an upload or download fails with the cause http_status or target_rejected after the host answered, an SDK MUST report the final HTTP status code with the failure. Where the client does not follow redirects itself and the platform reports a redirect answer without its status, the SDK MUST report that failure without a status.An app that cannot see the status cannot tell a 503 it can retry from a 403 it cannot.
ATCH-070Transfer deadlinesWhere the client opens its own connections, it MUST use a 30 s connect timeout for an upload PUT and a download GET, and MUST end the upload or download as failed with cause network when that timeout expires. Where the client opens its own connections and no bytes are sent or received for 60 s during an upload PUT, it MUST end the upload as failed with cause network; where the client can observe response reads and no response bytes are read for 60 s during a download GET, it MUST end the download as failed with cause network. The client MUST NOT apply a total-time cap to either request.A stalled target or host would keep an upload uploading or a download joined under ATCH-058 forever.

The upload contract is only as strong as the storage target. ATCH-023 depends on a target that enforces what the backend signs (ATCH-007). A target that enforces neither a signed SHA-256 nor a create-only precondition, such as Google Cloud Storage through its XML API, is not a supported target.

The client sends the upload PUT to whatever https URL, or loopback http URL, the backend it is bound to (CONF-030) signs, including a private address, so an on-premises storage target works. A backend that the operator does not control could direct a client’s PUT at an internal host; the body is ciphertext and carries no credential (ATCH-027), and ATCH-071 stops redirects. The client already trusts that backend with its messages.

When auth is disabled (AUTH-003), anyone who can reach the backend can store objects up to max_upload_bytes. When auth is enabled, any holder of a credential can. The backend cannot tell a ciphertext from any other bytes, so a deployment’s storage can be used to host arbitrary files. There is no per-caller quota.

An object is public to anyone who has its URL. Its confidentiality rests on the encryption of CTYPE-015 alone. The storage target and every download host learn the size of the object, the time of each fetch, and the network address of the downloader.

The backend does not apply retention. The operator’s expiry rule on the storage target does, and ATCH-005 cannot be checked by a client. The client does not read retention: it attempts every download an app asks for, and an expired object fails with not_found.

A remote attachment forwarded unchanged in another message shares its plaintext path, and one that carries the same key material with another filename shares its attachment key directory, so deleting the local files for one message deletes them for both, and the next download fetches them again.

A failed upload or download starts again from the first byte. There is no multipart or ranged transfer, and the largest single upload is bounded by the target’s own limit on one PUT as well as by max_upload_bytes.

A host that sends a byte at least every 60 s can keep a download, and every download joined to it under ATCH-058, running up to the ATCH-056 size bound. The app can end the download under ATCH-047.

In a browser, the SQLite store keeps every database in one origin-private pool, so the client names the inbox’s database by its {data_dir}/{deployment}/{inbox_id}/ path but cannot place it in that directory. An app that removes the directory also removes the attachments but not the database. In a browser, ATCH-054 rejects localhost names and private IP literals in a download URL while allow_private_network is false. The client cannot check the address to which another hostname resolves before the browser connects; the browser’s private-network rules apply to those hostnames. ATCH-055 ends the download on every redirect, including an opaque redirect. A browser client cannot set a connect timeout or observe upload progress of a fetch PUT, so its PUT has no deadline beyond the browser’s own. The client can observe response reads of a download GET and applies ATCH-070 to them. A download host that sends no CORS headers cannot serve a browser client.

Each client emits the attachment.* events only for the changes it makes. A second client on the same database, such as an app and its notification extension, reads the recorded status of a pending attachment but receives no event for an upload that the other client runs. The upload lease depends on the device clock and on a running process: a clock that jumps forward, or a platform that suspends the process for more than 120 seconds, expires a running upload’s lease, so a second upload of the same object can start. The first upload then leaves the record to the second (ATCH-074), and when both PUTs reach the target, the create-only precondition (ATCH-023) ends one of them with 412. A deletion ends only the downloads that the deleting client runs. A download of the same remote attachment that another client on the same database runs can complete after the deletion and put the plaintext file and its local attachment record back (ATCH-051, ATCH-063); the app can delete the local files again after that download ends.

The client follows symbolic links in the paths the app supplies, including a data directory, an attachments directory, and their parents, so a user who can change one of those paths can redirect the client’s files; the app supplies paths that other users cannot change. The client does not change the permissions of an attachments directory the app supplies; an app that supplies one that other local users can write gives them control of the files below it, but not of files outside it: where ATCH-077 applies, the client does not follow a symbolic link below the attachments directory, so a planted link fails the operation with local_storage. A managed directory below the attachments directory that already exists and that another user owns fails with local_storage (ATCH-077), so a file system that reports a fixed owner for every file, such as Android shared storage or some network mounts, cannot hold a client-managed attachments directory; app-private storage is not affected.

A browser reports a redirect answer to a fetch with manual redirect handling as an opaque redirect without its status, so a browser upload that ends on a redirect fails with target_rejected and no status.

On a file system without POSIX permissions, such as NTFS on Windows, ATCH-077 does not apply: the files the client writes inherit the access control list of the directory the app supplies. An app keeps attachments private to its user by placing the data directory or attachments directory under that user’s profile, such as %LOCALAPPDATA%, whose inherited access list grants only that user, administrators, and the system.

Two processes that share a data directory can each record a deployment identifier at the same time, and one record can be lost; the next online start records it again, and until then an offline start for that backend fails with a storage-location error.

Clients in separate processes, or in separate browser workers, that share one attachments directory do not coordinate creation, reconciliation, deletion, and downloads with each other. Reconciliation in one can record a plaintext file that deletion in another is removing, and deletion in one does not wait for a download that another is running. Reconciliation in one can also record the plaintext file of a creation that another then cancels. The cancelled creation removes its file, and the record stays until the next client creation removes it (ATCH-076). In a browser, a lock that all workers of the origin share stops two moves to one path from replacing each other; it does not cover reconciliation, deletion, or downloads. An app that runs clients in more than one process on one directory serializes deletion itself.

A deletion ends a running upload of the content digest before it removes the pending attachment record (ATCH-047). If the deletion fails after that point, the request that the upload served has already ended with the cause deleted, but the record stays. The record reads as waiting when the upload lease expires (ATCH-074), and the app can upload or delete again. This needs a failure of the local database or file system during the deletion.

When creation finds a staged ciphertext at its path whose SHA-256 is not its content digest, it replaces that file. It does this also while an upload of that digest in another client on the same database holds the upload lease (ATCH-074), and that upload then reads the new file. Each creation has a new secret, salt, and nonce (CTYPE-015), so this needs two ciphertexts with the same SHA-256, or damage to the staged file from outside the client, which makes that upload fail in any case (ATCH-036).

If the deployment record file cannot be parsed, the client treats it as empty and rewrites it at the next record. Until an online start records a backend again, an offline start for that backend fails with a storage-location error.

A client whose app disables background workers, or the attachment cleanup worker, removes expired pending attachments only at the next client creation (ATCH-068), so their staged ciphertext stays on the device until then.

A file that an app removes from the attachments directory without asking the client leaves its record behind until the next client creation, or until the app asks the client to delete the local files of that remote attachment.