How Couriers in BitChat Deliver Offline Messages: Store-and-Forward Explained

TLDR: BitChat uses Couriers — trusted peers in a Bluetooth mesh network — to carry sealed, encrypted messages to offline recipients via a store-and-forward mechanism, with optional internet fallback through Nostr bridges.

BitChat (from permissionlesstech/bitchat) is a privacy-focused messenger that relies on local-only mesh networking. When a recipient is offline or unreachable, the sender doesn't just queue the message locally — it hands a sealed CourierEnvelope to up to three trusted Couriers, who physically or virtually carry it until the recipient comes online. This design powers offline resilience while keeping content fully encrypted. In this article, we'll break down how couriers function in BitChat for message delivery, referencing the actual source code.

The Courier Architecture in BitChat

Couriers are not special server-side nodes. They are any connected, trusted peer in your mesh network — a friend, a mutual favorite, or a verified contact — that agrees to carry ephemeral payloads. The system implements a spray-and-wait strategy: the sender sprays copies of the envelope to several couriers, and at least one of them is expected to eventually deliver it.

The entire flow is orchestrated by three central building blocks:

  • CourierEnvelope — the sealed message container with a rotating tag and an expiry.
  • MessageRouter — picks couriers, retries deposits, and enforces budgets.
  • BridgeCourierService — the internet fallback that publishes envelopes as Nostr drops.

Best of all, every piece is open source. You can inspect the exact logic in the repository's localPackages and bitchat/Services folders.

Step 1: The Courier Envelope — Keeping the Message Sealed

A courier never sees the message content. The sender encrypt the plaintext into a CourierEnvelope before any routing decisions are made.

Structure of a CourierEnvelope

According to the code in CourierEnvelope.swift, the envelope includes:

  • recipientTag — a rotating tag derived from the recipient's Noise static key and the current UTC day. This makes it impossible for any courier to recognize who the message is for, except the intended peer.
  • expiry — a Unix timestamp in milliseconds after which the envelope is discarded. The source enforces a maximum of 24 hours via CourierEnvelope.maxLifetimeSeconds.
  • ciphertext — the encrypted payload, opaque to the courier.
  • copies — a copy budget that limits how many distinct couriers may hold a copy. This mitigates spam and amplification attacks.
  • prekeyID — optional identifier for forward‑secret (FS) envelopes, used when ratcheting is needed.

The envelope is TLV-encoded (Type-Length-Value), making it compact over the wire.

let recipientTag = CourierEnvelope.recipientTag(
    noiseStaticKey: recipientNoiseKey,
    epochDay: CourierEnvelope.epochDay(for: Date())
)

let envelope = CourierEnvelope(
    recipientTag: recipientTag,
    expiry: UInt64(Date().addingTimeInterval(24*60*60).timeIntervalSince1970 * 1000),
    ciphertext: encryptedPayload,
    copies: 3                        // allow up to 3 spray copies
)

Step 2: Selecting Eligible Couriers and Depositing the Envelope

When a direct send fails (no transport to the recipient), the MessageRouter takes over. In MessageRouter.swift, the eligibleCouriers method builds a list of potential carriers by filtering connected peers:

  • Peers that are verified (mutual TLS or trust knowledge)
  • Peers that are in the mutual‑favorite set

The available set is then returned as eligibleCouriers. The router then picks up to maxCouriersPerMessage (currently 3) and hands the envelope to them via the Bluetooth mesh.

The actual deposit happens through MeshCourierTransporting.sendCourierMessage, and a successful delivery record is stored locally via recordCourierDeposit. This prevents over‑spraying to the same courier later.

Internally, the flow looks like this (simplified):

let candidates = router.eligibleCouriers(
    on: meshTransport,
    recipientKey: recipientNoiseKey,
    excluding: [],                  // no prior deposits
    limit: MessageRouter.maxCouriersPerMessage
)

meshTransport.sendCourierMessage(
    envelope.ciphertext,            // opaque payload
    messageID: messageID,
    recipientNoiseKey: recipientNoiseKey,
    via: candidates.map(\.peerID)
)

Note that sendPrivate in the router encapsulates these steps, so the caller just provides the message content, recipient ID, and a message ID.

Step 3: Retry & Spread – How Couriers Ensure Delivery

The intelligence of courier delivery lies in BitChat's retry loop. If a courier is offline at sending time, the message stays in the outbox. When any eligible courier comes online (e.g., a favorite peer appears nearby), the courierBecameAvailable method is invoked.

Inside courierBecameAvailable, the router:

  1. Re-checks the pending messages in the outbox.
  2. Builds a fresh list of eligible couriers.
  3. Re‑deposits envelopes to those couriers, respecting the maxCouriersPerMessage limit (3) and the per‑envelope copies budget (8).

If a courier disconnects before delivering, the envelope remains in the outbox. No data is lost — the router simply waits for the next eligible courier to appear.

This "spray‑and‑wait" approach also throttles abuse: each envelope can be duplicated at most CourierEnvelope.maxCopies (8) times across all couriers, and any envelope older than maxLifetimeSeconds (24h) is silently dropped.

Internet Fallback: When No Local Courier Is Enough

In sparse mesh topologies, physical couriers might never reach the recipient within the 24‑hour window. For that, BitChat includes BridgeCourierService.swift, which acts as an internet client.

This service:

  • Takes a sealed CourierEnvelope.
  • Publishes it as a Nostr "drop" — an anonymous encrypted event on the Nostr network.
  • The recipient can later query Nostr for envelopes tagged with their rotating recipientTag.

This expands delivery beyond Bluetooth range, making courier mechanics work globally, while keeping all messages encrypted end‑to‑end.

Key Files in the Source

File Role
CourierEnvelope.swift Defines the TLV‑encoded envelope, recipient‑tag derivation, encoding/decoding, and copy‑budget logic.
MessageRouter.swift Orchestrates message routing, selects couriers, records deposits, and retries when couriers become available.
BridgeCourierService.swift Provides the internet fallback – publishes sealed envelopes as Nostr drops.
MockTransport.swift Test harness that records sentCourierMessages to verify courier‑deposit behavior.
MessageRouterTests.swift Unit tests exercising courier selection, retry logic, and copy‑budget enforcement.

Summary

  • Couriers are regular peers that transport encrypted CourierEnvelopes when the recipient is offline.
  • The envelope is TLV‑encoded and has 24‑hour expiry, a rotating recipient tag, and a copy budget to thwart abuse.
  • MessageRouter selects up to 3 couriers and re‑deposits envelopes whenever a new eligible courier appears.
  • The BridgeCourierService provides a Nostr‑based internet fallback, ensuring delivery even outside the mesh.
  • The whole system is privacy‑preserving — couriers never see the sender, recipient, or content.

Frequently Asked Questions

What is the Copies limit in BitChat Couriers?

Copies is a budget that controls how many times a single CourierEnvelope may be distributed to different couriers. The default is 3 per message, but the envelope itself caps the absolute maximum at 8 (via maxCopies). This prevents message blasting across the network.

How does the bPriority-encrypted recipient tag work?

The tag is a deterministic hash of the recipient's Noise static key and the current UTC day. It rotates daily, so the courier only sees a new opaque string each day. Only the intended recipient can match the tag to themselves, which hides the identity of the recipient from all couriers.

Can a courier read the message?

No. The courier only stores and forwards opaque ciphertext. All encryption happens before creating the envelope, and the courier cannot decrypt the contents.

What happens if the recipient comes back online but a courier is still offline?

If the recipient is already online, the sender can deliver directly, bypassing couriers. If the message is in the outbox for a courier, the sender will attempt a direct send first. If direct fails, the retry mechanism (courierBecameAvailable) continues to look for new couriers until the envelope expires (24h).

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →