Receive-cache retention API
This guide extends the integration API settings contract. All examples use the actual existing listener and /api/legnasend/v1/integration prefix. The API must be enabled. Use a key explicitly granting settings.read, settings.write and the global workspace grant *; anonymous access does not gain these capabilities. Read-only clients need only settings.read and *.
Values and scope
POST /settings/update accepts one field per request:
{"version":"VERSION_FROM_READ","field":"receiveCacheRetentionDays","value":7}value must be a JSON integer between −2 and 3650 inclusive. -2 means one hour (3,600,000 milliseconds); -1 keeps registered native receive leftovers until explicit manual cleanup; 0 allows immediate automatic cleanup; positive values retain them for that many days. The native settings UI offers −2, −1, 0, 1, 7 and 30; the API also accepts other integers in range. Strings ("7"), booleans (true), floating-point JSON tokens (7.0), null and out-of-range values are rejected, not coerced.
This setting applies to registered non-resumable native staging after an unexpected exit; ordinary cancellation and non-resumable failures still clean their transaction cache. New durable recovery uses a separate absolute one-hour lease; existing legacy records keep their original one-day deadline, retaining confirmed blocks on transport failure; this setting does not extend it. Startup/API cleanup preserves valid leases, while explicit local manual cleanup can reclaim inactive owned caches. Active records, outputs and identity mismatches remain protected. Summary reason durable_resume means recovery records were checked; durable_resume_failed denotes storage-check failure, not reclaimed space. See the durable contract. Workspace uploads, private workspace-export copies and Android document-provider transactions are separate. Retention does not provide partial resume for the original LocalSend protocol.
Updating this setting persists and synchronizes policy; it does not run cleanup, bypass age, stop a transfer or restart the listener. API POST /cache/cleanup still requires cache.clean and respects retention. Only the separately confirmed local receive-cache maintenance dialog overrides age; ownership, header and active-writer protection remain in force. A timed entry becomes eligible on the next maintenance pass, not at an exact scheduled deletion instant.
Read saved and effective state separately
GET /settings and a successful update return the ordinary versioned settings snapshot plus a required top-level receiveCacheRetention object. Example excerpt:
{
"version": "64_HEX_DIGEST_FROM_THE_HOST",
"settings": {"receiveCacheRetentionDays": 7},
"pendingRestart": [],
"receiveCacheRetention": {
"effectiveDays": 7,
"automaticCleanupPaused": false,
"busy": false,
"error": null
}
}The example omits unrelated settings. settings.receiveCacheRetentionDays is the saved integer preference; it is not proof that native configuration succeeded.
| Runtime field | Type and meaning |
|---|---|
effectiveDays | Integer −2…3650, or null when the actual native policy has not been confirmed |
automaticCleanupPaused | Boolean; whether automatic native-cache cleanup is currently paused |
busy | Boolean; a settings policy operation is in progress |
error | null, invalid, save, apply, or restore |
invalid denotes damaged saved preferences; save is a persistence failure, apply a synchronization failure, and restore a failed preference recovery. The host queries actual native policy after failures where possible. A returned effectiveDays can therefore differ from the saved preference. Read every field; neither error != null nor a saved value alone determines whether automatic cleanup is paused. An unknown native policy or uncertain preference recovery is kept paused.
automaticCleanupPaused is the synchronization safety gate, not the retention mode. A successfully applied manual policy can report effectiveDays: -1 and automaticCleanupPaused: false: automatic maintenance may run but retains eligible native leftovers because manual retention is active. This never bypasses age or active-writer checks. Unknown policy (effectiveDays: null) and an in-progress change (busy: true) report a paused gate. A reconciled old policy after save/apply failure may leave the gate unpaused while preserving the diagnostic.
Optimistic concurrency and outcomes
- Read
GET /settingsand retain its exactversion. - Review the saved and effective states before preparing one mutation.
- Send one update with that version. Wait for persistence and native policy acknowledgement.
- On
409 settings_changed, read again and decide whether the intent still applies; do not simply replace the version and blindly resubmit. - On
409 settings_busy, wait for the existing operation, then read state again. Do not queue automatic mutations. - On
503 host_operation_failed, read actual state. Persistence rollback or native synchronization may have failed; the failure is not proof that nothing changed. - After a network error, host deadline or
outcome_unknown, query state rather than replaying the mutation automatically.
The version includes saved settings, pending-restart state and the complete retention runtime snapshot. It is an optimistic content digest, not a monotonically increasing revision. Transient busy or error-state changes can invalidate a version. Equal snapshots can produce equal versions. These operations are not request-ID deduplicated. The in-app API explorer validates integer input, uses in-page confirmation for each write, and displays response JSON including actual-state diagnostics in four supported interface locales.
cURL
Requires curl and jq. BASE is the actual listener URL. Keep tokens in process environment rather than URLs. If using HTTPS, configure the trusted device certificate in curl; do not suppress certificate verification.
BASE='http://HOST:PORT/api/legnasend/v1/integration'
DAYS=7
SNAPSHOT=$(curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $LEGNASEND_API_TOKEN" "$BASE/settings")
VERSION=$(printf '%s' "$SNAPSHOT" | jq -er '.version')
BODY=$(jq -nc --arg version "$VERSION" --argjson days "$DAYS" \
'if ($days|type)=="number" and ($days|floor)==$days and $days>=-2 and $days<=3650
then {version:$version,field:"receiveCacheRetentionDays",value:$days}
else error("Expected integer -1..3650") end')
curl --include --request POST \
-H "Authorization: Bearer $LEGNASEND_API_TOKEN" -H 'Content-Type: application/json' \
--data-binary "$BODY" "$BASE/settings/update"
# Read state after an uncertain or failed outcome; no automatic repeat POST.
curl --fail-with-body -H "Authorization: Bearer $LEGNASEND_API_TOKEN" "$BASE/settings"For a deliberate negative test against a fresh version, replace the JSON value with true or "7": the endpoint returns 400 instead of treating either as an integer. Do not use jq string output (--arg days) for the valid request. jq normalizes integral numbers to integer JSON; the wire contract, not a language's number representation, controls acceptance.
JavaScript
The browser still follows configured CORS and TLS policy. token should come from an explicit secret input; do not embed it in URLs or committed source.
const base = 'http://HOST:PORT/api/legnasend/v1/integration';
const headers = {Authorization: `Bearer ${token}`};
async function readSettings() {
const response = await fetch(`${base}/settings`, {headers, credentials: 'omit'});
if (!response.ok) throw new Error(`Read failed: ${response.status}`);
return response.json();
}
async function setRetention(days) {
// No Number(days): that would silently accept true or numeric strings.
if (!Number.isInteger(days) || days < -2 || days > 3650) {
throw new TypeError('Expected integer -1..3650');
}
const snapshot = await readSettings();
const response = await fetch(`${base}/settings/update`, {
method: 'POST', credentials: 'omit',
headers: {...headers, 'Content-Type': 'application/json'},
body: JSON.stringify({version: snapshot.version, field: 'receiveCacheRetentionDays', value: days})
});
console.log(response.status, await response.json());
if (!response.ok) console.log('Current state:', await readSettings());
// A caller handles network errors by reading state, never by repeating POST.
}
await setRetention(7);
// setRetention(true) and setRetention('7') reject locally.Python
Python bool is a subclass of int; use type(days) is int, not just isinstance(days, int).
import json, os, urllib.request, urllib.error
BASE = 'http://HOST:PORT/api/legnasend/v1/integration'
HEADERS = {'Authorization': 'Bearer ' + os.environ['LEGNASEND_API_TOKEN']}
def call(method, route, payload=None):
data = None if payload is None else json.dumps(payload).encode('utf-8')
headers = {**HEADERS, **({'Content-Type': 'application/json'} if data is not None else {})}
request = urllib.request.Request(BASE + route, data=data, headers=headers, method=method)
try:
response = urllib.request.urlopen(request, timeout=40)
except urllib.error.HTTPError as response_error:
response = response_error
with response:
return response.status, json.loads(response.read(262144))
def set_retention(days):
if type(days) is not int or not -2 <= days <= 3650:
raise TypeError('Expected integer -1..3650')
status, snapshot = call('GET', '/settings')
if status != 200:
raise RuntimeError(('Read failed', status, snapshot))
result = call('POST', '/settings/update', {
'version': snapshot['version'], 'field': 'receiveCacheRetentionDays', 'value': days})
print(result)
if result[0] != 200:
print('Current state:', call('GET', '/settings'))
# Network exceptions propagate; inspect current state before deciding anew.
set_retention(-1)
# set_retention(True), set_retention('7'), set_retention(7.0) reject locally.For HTTPS use a trusted device CA with ssl.create_default_context(cafile='DEVICE_CA.pem') and pass the context to urlopen; provide a client certificate if the listener requires it. The original transfer protocol, certificate identity checks and file formats are unchanged.
Unset preferences now default to one hour; existing explicit values are preserved. The historical receiveCacheRetentionDays field uses −2 as an hour-policy code, not negative days. The native bridge uses {mode:"hour",days:null}. Browser single-file and original-file batches share the one-hour default. New Android SAF recovery records also use one hour; legacy records keep their original deadlines.
For durable records, activity delays local cleanup only, not the already advertised recovery authorization deadline. Active writers and completed files remain protected.