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
- Parse the 409 body's missing/extra device IDs, fetch new prekeys for missing devices, drop sessions for extra devices, and re-encrypt/resend
- Refresh the device list and prekeys for affected recipients before the next send
- Re-run your normal single-recipient 409 handling per mismatched account
- 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
- Keep recipient device-list caches short-lived
- Handle 409 reconciliation as a first-class flow, not an error path
- Fetch fresh prekeys for any missing device before re-encrypting
- Drop sessions for extra device IDs promptly
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
- .status(410)
- 409 Conflict (session already verified)
- Empty body not allowed
- Got a non-200 reply from source URI:
- return Response.status(428).build();
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)