signalapp/Signal-Server · error · WebApplicationException

.status(409)

Error message

.status(409)

What it means

sendMultiRecipientMessage returns HTTP 409 Conflict when one or more recipient accounts have mismatched devices: the client encrypted for a device set that differs from the server's current set of active devices. The 409 body is a JSON list of AccountMismatchedDevices, each carrying the account id plus missingDeviceIds and extraDeviceIds so the sender can re-derive session keys and resend.

Solutions

  1. Parse the 409 body's missing/extra device IDs, fetch new prekeys for missing devices, drop sessions for extra devices, and re-encrypt/resend
  2. Refresh the device list and prekeys for affected recipients before the next send
  3. Re-run your normal single-recipient 409 handling per mismatched account
  4. If it happens constantly, check for registration races causing device churn

Example fix

// before: treat any non-2xx as failure and abort the whole group send
if (!response.isSuccessful()) throw new IOException("send failed");
// after: reconcile on 409
if (response.code() == 409) {
  List<AccountMismatchedDevices> mm = parseMismatched(response.body());
  for (AccountMismatchedDevices a : mm) {
    refreshSessionsFor(a.accountId(), a.mismatchedDevices().missingDeviceIds(), a.mismatchedDevices().extraDeviceIds());
  }
  resend();
}
Defensive patterns

Strategy: fallback

Validate before calling

// refresh recipient device lists before a multi-recipient send
for (Recipient r : recipients) {
  deviceListCache.refreshIfStale(r, maxAgeSeconds = 300);
}

Try / catch

if (response.code() == 409) {
  List<AccountMismatchedDevices> mismatches = parseBody(response);
  mismatches.forEach(m -> sessionStore.rebuildSessions(m.accountId(), m.missing(), m.extra()));
  resend();
}

Prevention

When it happens

Trigger: POSTing a multi-recipient message sealed for device IDs that are stale relative to server state — a recipient added a new device (missing) or removed/retired one (extra) after the sender last fetched keys.

Common situations: Recipients linking a new device or re-registering (which bumps device IDs) while senders cache old key material; bulk group sends where at least one member changed devices; long-lived clients not refreshing prekeys/device lists.

Understand the failure class

Background: "API error: {status}" and "HTTP 401/403/404/429/5xx" errors: non-2xx HTTP responses explained — this error's family across 27 libraries.

Related errors


AI-assisted analysis of signalapp/Signal-Server@100ab61c82 (2026-09-09). Data as JSON: /api/errors/09dc3d4f333e1afb. Report an issue: GitHub.

Appendix: source

Thrown at service/src/main/java/org/whispersystems/textsecuregcm/controllers/MessageController.java:671

    } catch (final CancellationException e) {
      logger.error("cancelled while delivering multi-recipient messages", e);
      throw new InternalServerErrorException("delivery cancelled");
    } catch (final ExecutionException e) {
      logger.error("partial failure while delivering multi-recipient messages", e.getCause());
      throw new InternalServerErrorException("failure during delivery");
    } catch (final MessageTooLargeException e) {
      throw new WebApplicationException(Status.REQUEST_ENTITY_TOO_LARGE);
    } catch (final MultiRecipientMismatchedDevicesException e) {
      final List<AccountMismatchedDevices> accountMismatchedDevices =
          e.getMismatchedDevicesByServiceIdentifier().entrySet().stream()
              .filter(entry -> !entry.getValue().missingDeviceIds().isEmpty() || !entry.getValue().extraDeviceIds().isEmpty())
              .map(entry -> new AccountMismatchedDevices(entry.getKey(),
                  new MismatchedDevicesResponse(entry.getValue().missingDeviceIds(), entry.getValue().extraDeviceIds())))
              .toList();

      if (!accountMismatchedDevices.isEmpty()) {
        throw new WebApplicationException(Response
            .status(409)
            .type(MediaType.APPLICATION_JSON_TYPE)
            .entity(accountMismatchedDevices)
            .build());
      }

      final List<AccountStaleDevices> accountStaleDevices =
          e.getMismatchedDevicesByServiceIdentifier().entrySet().stream()
              .filter(entry -> !entry.getValue().staleDeviceIds().isEmpty())
              .map(entry -> new AccountStaleDevices(entry.getKey(),
                  new StaleDevicesResponse(entry.getValue().staleDeviceIds())))
              .toList();

      throw new WebApplicationException(Response
          .status(410)
          .type(MediaType.APPLICATION_JSON)
          .entity(accountStaleDevices)
          .build());
    } catch (final MessageDeliveryNotAllowedException e) {

View on GitHub (pinned to 100ab61c82)