---
title: "Update discovery"
method: PUT
path: "/api/v1/discoveries/{id}"
tags: ["Discoveries"]
---

# Update discovery

`PUT /api/v1/discoveries/{id}`

## Request

- Base URL: `https://app.scanopy.net`
- URL: `https://app.scanopy.net/api/v1/discoveries/{id}`
- Auth: one of:
  - API key in header `Authorization`
  - API key in cookie `session_id`

## Path parameters

- `id` string, uuid, required

## Request body

- Discovery
  - `daemon_id` string, uuid, required: The daemon this entity refers to.
  - `discovery_type` union, required
    - object
      - `host_id` string, uuid, required: The host the daemon is running on.
      - `type` 'SelfReport', required
    - object
      - `host_naming_fallback` 'Ip' | 'BestService', required
      - `snmp_credentials` object: SNMP credentials for querying devices during discovery Server builds this mapping before initiating discovery
      - `subnet_ids` string[], nullable, required: Subnets to sweep. `null` sweeps every subnet on the site.
      - `type` 'Network', required
    - object
      - `host_id` string, uuid, required: The host the daemon is running on.
      - `host_naming_fallback` 'Ip' | 'BestService', required
      - `type` 'Docker', required
    - object: A one-shot verification of known hosts: re-check the addresses and ports already recorded for them, rather than sweeping a subnet. One rescan covers every requested host that the same daemon last found. Created by the server only (never via the API) and deleted once its session reaches a terminal phase, so it is not a discovery configuration anyone owns or sees in their scan list.
      - `host_id` string, uuid, required: ID of the host that the daemon is running on — same meaning as every other variant. The hosts being rescanned are `target_host_ids`.
      - `ips` string[], required: Addresses to scan on those hosts.
      - `ports` PortType[]: Ports already known on those hosts, re-checked on every address to confirm they are still open. Scanned in addition to the standard discovery set, so a rescan also surfaces newly-opened services.
        - `number` integer, required: TCP or UDP port number
        - `protocol` 'Udp' | 'Tcp', required: Transport protocol the port is open on.
        - `type` 'Ssh' | 'Telnet' | 'DnsUdp' | 'DnsTcp' | 'Samba' | 'Nfs' | 'Ftp' | 'Ipp' | 'LdpTcp' | 'LdpUdp' | 'Ldap' | 'Ldaps' | 'Kerberos' | 'Snmp' | 'SnmpAlt' | 'Rdp' | 'Ntp' | 'Sip' | 'SipTls' | 'Rtsp' | 'Dhcp' | 'Http' | 'MySql' | 'PostgreSQL' | 'MongoDB' | 'Redis' | 'MsSql' | 'Docker' | 'DockerTls' | 'Kubernetes' | 'RabbitMqMgmt' | 'Cassandra' | 'Elasticsearch' | 'InfluxDb' | 'CouchDb' | 'Kafka' | 'Http3000' | 'Http5000' | 'Http8080' | 'Http8081' | 'Http8082' | 'Http8888' | 'Http9000' | 'Https' | 'Https8443' | 'Https9443' | 'Https10443' | 'Mqtt' | 'MqttTls' | 'AMQP' | 'AMQPTls' | 'Wireguard' | 'OpenVPN' | 'BACnet' | 'ModbusTcp' | 'OpcUa' | 'EtherNetIp' | 'JetDirect' | 'Custom': Well-known port identifier. Auto-derived from number+protocol, so it is optional on create.
      - `settings` RescanSettings: Scan settings that apply to a single-host rescan. Deliberately narrower than [`ScanSettings`]: a rescan verifies a known host against a known port set, so the full-scan mechanism (`is_full_scan`, `full_scan_interval`) must not be expressible — promoting a rescan to a 65,535-port sweep defeats the feature. The remaining omissions are settings that cannot bind on a one-or-two address target.
        - `arp_retries` integer, nullable: ARP retry rounds. Matters more here than in a sweep: for a rescan, "did it answer" is the entire answer, so a missed round reads as a dead host.
        - `port_scan_batch_size` integer, nullable: Ports scanned concurrently per host.
        - `probe_raw_socket_ports` boolean: Whether to probe raw-socket ports 9100-9107. Correctness-affecting: with this off the scanner drops those ports from its results, so a printer's known JetDirect port would look like it had disappeared.
        - `scan_rate_pps` integer, nullable: Port scan probes per second. Operators lower this for fragile devices or noisy IDS, and a rescan must respect that as much as a discovery does.
        - `trust_port_only_detections` boolean: Carried into a rescan: a rescan of a host on a routed subnet runs the same evidence check, and it would be strange for a host to survive a scan and vanish on rescan, or the reverse.
        - `use_npcap_arp` boolean: On Windows, use Npcap broadcast ARP instead of SendARP.
      - `target_host_ids` string[], required: The hosts being rescanned. Reads the single `target_host_id` that rescans stored before multi-host rescans carry, and that older daemons echo back, as a list of one.
      - `type` 'Rescan', required
    - object
      - `host_id` string, uuid, required: ID of the host that the daemon is running on
      - `host_naming_fallback` 'Ip' | 'BestService', required
      - `scan_settings` ScanSettings: Scan performance settings. Lives on the discovery entity. Numeric fields are `Option<T>` — `None` means "use daemon default". The daemon unwraps with defaults at point of use.
        - `arp_rate_pps` integer, nullable: ARP packets per second (default: 50)
        - `arp_retries` integer, nullable: ARP retry rounds for non-responsive targets (default: 2 = 3 total attempts)
        - `arp_scan_cutoff` integer, nullable: ARP scan cutoff prefix. Interfaced subnets larger than this prefix are truncated to this many IPs. Default: 15 (= /15, ~131K IPs). Lower values scan more IPs — increase arp_rate_pps accordingly.
        - `full_scan_interval` integer, nullable: Run a full 65k port scan every N scans. Other scans use a light port set. Default: 3. Value of 0 means never full scan. Value of 1 means every scan is full.
        - `is_full_scan` boolean: Whether this specific scan run should do a full 65k port scan. Set by the server before dispatching to the daemon — not user-configurable.
        - `max_discovery_duration` integer, nullable: Hard ceiling on how long a single discovery run may take, in seconds (default: 21600 = 6h). When hit, the run force-completes and any hosts still queued are left un-scanned until the next run. Raise this for very large networks that legitimately need more than the default window.
        - `port_scan_batch_size` integer, nullable: Ports scanned concurrently per host (default: 200, clamped 16-1000)
        - `probe_raw_socket_ports` boolean: Whether to probe raw-socket ports 9100-9107 (default: false). Disabled by default to prevent ghost printing on JetDirect printers.
        - `scan_rate_pps` integer, nullable: Port scan probes per second (default: 500)
        - `trust_port_only_detections` boolean: Whether an open TCP port alone is enough to record a host on a subnet the daemon reaches *through* something (default: false). On a subnet the daemon has an interface on, ARP answers first and this never applies. On a routed one every address arrives enumerated, and a middlebox that completes the TCP handshake for addresses with nothing behind them — a FortiGate session helper, a load balancer, a scrubbing appliance — makes every address look open. With this off, such an address is recorded only when something actually answered: a probe that spoke the protocol, an endpoint that replied, a credential that authenticated. Turning it on restores the older behaviour for hosts whose every open port is one Scanopy cannot interrogate. That is a real class of host — a bespoke TCP service on a routed subnet — which is why the escape hatch exists rather than the rule simply being absolute.
        - `use_npcap_arp` boolean: On Windows, use Npcap broadcast ARP instead of SendARP (default: false)
      - `subnet_ids` string[], nullable, required: Subnets to scan. None = scan all interfaced subnets.
      - `type` 'Unified', required
  - `name` string, required: Human-facing name for this discovery.
  - `run_type` union, required
    - object
      - `cron_schedule` string, required: Cron expression deciding when the scan runs.
      - `enabled` boolean, required: Whether the schedule is active.
      - `last_run` string, date-time, nullable: When the scan last ran.
      - `timezone` string, nullable: IANA timezone for cron evaluation, e.g. "America/New_York". None = UTC.
      - `type` 'Scheduled', required
    - object: Historical discovery runs are created by the server and cannot be submitted via API
      - `results` DiscoveryUpdatePayload, required: Progress update from daemon to server during discovery
        - `credential_results` CredentialRunResult[]: What each stored credential did in this run, one entry per credential. Results, not problems: those are `warnings`. Old servers ignore it. Read per entry, like `warnings`: an outcome shape from a newer daemon becomes `Unknown` for that credential instead of failing the terminal payload.
          - `credential_id` string, uuid, required: The credential these results belong to.
          - `outcome` union, required
            - object: An integration that collects from a host: how many hosts it collected from.
              - …
            - object: One entry per host the script ran on.
              - …
            - object: One entry per address the daemon tried to wake.
              - …
            - object: An outcome from a newer daemon than this server.
              - …
        - `daemon_id` string, uuid, required: The daemon this entity refers to.
        - `daemon_version` string, nullable: The daemon's version when the run ended. Stamped by the server at terminal, so it survives the daemon being deleted or upgraded.
        - `discovery_id` string, uuid, nullable: The discovery configuration this session belongs to. Always enriched server-side; daemons do not send this field.
        - `discovery_type` union, required
          - object
            - `host_id` string, uuid, required: The host the daemon is running on.
            - `type` 'SelfReport', required
          - object
            - `host_naming_fallback` 'Ip' | 'BestService', required
            - `snmp_credentials` object: SNMP credentials for querying devices during discovery Server builds this mapping before initiating discovery
            - `subnet_ids` string[], nullable, required: Subnets to sweep. `null` sweeps every subnet on the site.
            - `type` 'Network', required
          - object
            - `host_id` string, uuid, required: The host the daemon is running on.
            - `host_naming_fallback` 'Ip' | 'BestService', required
            - `type` 'Docker', required
          - object: A one-shot verification of known hosts: re-check the addresses and ports already recorded for them, rather than sweeping a subnet. One rescan covers every requested host that the same daemon last found. Created by the server only (never via the API) and deleted once its session reaches a terminal phase, so it is not a discovery configuration anyone owns or sees in their scan list.
            - `host_id` string, uuid, required: ID of the host that the daemon is running on — same meaning as every other variant. The hosts being rescanned are `target_host_ids`.
            - `ips` string[], required: Addresses to scan on those hosts.
            - `ports` PortType[]: Ports already known on those hosts, re-checked on every address to confirm they are still open. Scanned in addition to the standard discovery set, so a rescan also surfaces newly-opened services.
              - …
            - `settings` RescanSettings: Scan settings that apply to a single-host rescan. Deliberately narrower than [`ScanSettings`]: a rescan verifies a known host against a known port set, so the full-scan mechanism (`is_full_scan`, `full_scan_interval`) must not be expressible — promoting a rescan to a 65,535-port sweep defeats the feature. The remaining omissions are settings that cannot bind on a one-or-two address target.
              - …
            - `target_host_ids` string[], required: The hosts being rescanned. Reads the single `target_host_id` that rescans stored before multi-host rescans carry, and that older daemons echo back, as a list of one.
            - `type` 'Rescan', required
          - object
            - `host_id` string, uuid, required: ID of the host that the daemon is running on
            - `host_naming_fallback` 'Ip' | 'BestService', required
            - `scan_settings` ScanSettings: Scan performance settings. Lives on the discovery entity. Numeric fields are `Option<T>` — `None` means "use daemon default". The daemon unwraps with defaults at point of use.
              - …
            - `subnet_ids` string[], nullable, required: Subnets to scan. None = scan all interfaced subnets.
            - `type` 'Unified', required
        - `error` string, nullable: Failure message, when the run did not complete.
        - `estimated_remaining_secs` integer, nullable: Rough estimate of the time left, in seconds.
        - `finished_at` string, date-time, nullable: When the run finished. `null` while it is still going.
        - `hosts_discovered` integer, nullable: Hosts found so far.
        - `last_update_at` string, date-time, nullable: When the server last heard about this run. Stamped by the server at terminal.
        - `phase` 'AwaitingSnapshot' | 'Queued' | 'Pending' | 'Starting' | 'Started' | 'Scanning' | 'Complete' | 'Failed' | 'Cancelled', required
        - `progress` integer, required: Completion of the current phase, from 0 to 1.
        - `reason` 'Completed' | 'UserCancelled' | 'DaemonReportedFailure' | 'StalledNoUpdates' | 'DaemonRestarted' | 'DaemonUnreachable' | 'DaemonBusy' | 'WatchdogTimeout' | 'DaemonOffline' | 'SessionSilent' | 'DaemonDroppedSession': Why a session reached its terminal phase. `phase` says *that* a run ended; this says *why*. A stall reaped by the server, a daemon that lost its state in a restart and a user pressing cancel all used to land as the same phase with no way to tell them apart afterwards, which is what made stall reports undiagnosable. The server decides every reason except the two the daemon alone can know, see [`Self::daemon_assigned`]. Absent on runs recorded before this field existed.
        - `scanned` ScannedEntityIds: Canonical IDs of entities scanned in a discovery session. Populated daemon-side at terminal phase from `EntityBuffer`'s `Created` entries. Travels with the terminal `DiscoveryUpdatePayload` to the server, rides the in-memory `EntityOperation::Created` event published for the historical Discovery row (the event scope carries `Entity::Discovery` with the full struct, including `run_type::Historical { results }`), then is stripped before persisting into the historical Discovery row's JSONB (see the `SqlValue::RunType` bind_value handler in `backend/src/server/shared/storage/generic.rs`). Per-entity-service subscribers extract `results.scanned` from the in-memory event and call `DiscoveryFkUpdater::update_discovery_fks` to backfill `last_discovery_id` / `first_discovery_id` on the matched rows. Naming: `scanned_*` because the daemon scans entities — some submissions match existing rows (refresh), others insert new rows. Both populate the EntityBuffer with canonical (server-assigned) IDs.
          - `binding_ids` string[]: Service bindings touched by this discovery.
          - `found_subnet_ids` string[]: Subnets this discovery found without sweeping: ranges riding in a host request, ranges the server inferred or placed addresses into, and ranges holding an address the scan saw. Filled by the server, never by a daemon. Kept apart from `subnet_ids` because the digest reads that list as the ranges the scan *swept*, and counting a range it only found evidence in would report every unobserved host there as stale. The discovery FKs take both.
          - `host_ids` string[]: Hosts touched by this discovery.
          - `interface_ids` string[]: Interfaces touched by this discovery.
          - `ip_address_ids` string[]: IP addresses touched by this discovery.
          - `port_ids` string[]: Ports touched by this discovery.
          - `service_ids` string[]: Services touched by this discovery.
          - `subnet_ids` string[]: Subnets touched by this discovery.
          - `vlan_ids` string[]: VLANs touched by this discovery.
        - `session_id` string, uuid, required: The discovery run this update belongs to.
        - `site_id` string, uuid, required: The site this entity belongs to.
        - `started_at` string, date-time, nullable: When the run started.
        - `warnings` DiscoveryWarning[]: Non-fatal findings from a completed run — one per occurrence, each carrying the code that identifies it and the detail that fills the sentence. Unlike `error`, these do not mark the run failed. Read through [`deserialize_warnings`] rather than the derived impl, which is what keeps historical records and pre-coded daemons rendering: both send bare strings here, and both land as `Unknown` carrying that text instead of failing the whole payload.
          - union: A single non-fatal finding from one discovery run, about one device, neighbour, or the scan itself. Serialized with the code as the tag, so the generated TypeScript is a discriminated union the UI can switch on exhaustively. The derived `Deserialize` reads that shape; the leniency that keeps historical records and pre-coded daemons working lives in [`deserialize_warnings`], which is applied at the one field that holds these.
            - object: The interface *set* was cut short, so interfaces are genuinely missing.
              - …
            - object: The set was complete and only the attribute columns fell short, so nothing is missing — some descriptions or speeds are just blank. Kept apart from the above because reporting this as possible data loss sends people hunting for interfaces that were never absent.
              - …
            - object: Stopped at our own entry cap. The device is fine and larger than we read.
              - …
            - object: The device does not implement this MIB. Not a fault, and no later scan will change it.
              - …
            - object: The agent answered out of step with what was asked — stale or non-advancing responses.
              - …
            - object: A partial read whose rows are thrown away rather than recorded, so the device contributes nothing for this group however much it answered.
              - …
            - object: A partial read whose rows were recorded as far as they got.
              - …
            - object: Every request for the root of the bridge MIB timed out, which is how switches that do not implement it commonly answer.
              - …
            - object: The read returned nothing, and its cause was not recorded: a daemon from before causes were carried, or a read that ended without a failed request.
              - …
            - object: The read returned nothing, and `cause` is how its last request ended.
              - …
            - object: The device published a count, and the read ended before reaching it.
              - …
            - object: The device published a count, the read finished, and it came up short anyway.
              - …
            - object: The device declared the capability, and the read ended without returning any.
              - …
            - object: The device declared the capability, the read finished, and it returned none.
              - …
            - object: Neighbours whose local port matched no interface, so they were discarded entirely.
              - …
            - object: The same loss, on a device whose own account of its port numbering was only half read. Separate from [`Self::LldpLocalPortDropped`] because that one names a cause — the switch numbering its LLDP ports apart from its interfaces — which is a property of the device and is not what happened here. Offering both readings in one sentence, as this used to, asks the operator to pick; one says nothing will ever change and the other says a complete scan may well place these neighbours (GH #668).
              - …
            - object: Neighbours whose local port could not be identified but did match an interface number, so they are drawn against a port that may be the wrong one.
              - …
            - object: The column carrying the identifier stopped early, so a rescan may recover these.
              - …
            - object: Rows that never appeared in the identifying column at all.
              - …
            - object: Neighbours listed and then never given an identifier.
              - …
            - object: The identifying column held a value of a type it cannot hold.
              - …
            - object: The record's position in the neighbour table could not be read.
              - …
            - object: SNMP answered and every table came back empty.
              - …
            - object: The device answered correctly and persisting its VLANs failed.
              - …
            - object: Two integrations that each read the device's full interface table both collected it, and their interface sets were merged. A supported configuration, not a fault. It is reported because the merge keeps one row per port: where both describe a port, the first to answer supplies the row and its neighbours, and the second's are dropped. Someone chasing a missing link needs to know that happened.
              - …
            - object: The credential's address is not on any subnet this scan covers.
              - …
            - object: Nothing answered at the credential's address during the scan.
              - …
            - object: The credential's files or sockets were declared for one OS and this daemon runs another, so it was not used here.
              - …
            - object: The daemon is too old for part of how this credential is configured, so the server withheld that part (or, for a type the daemon cannot parse, all of it). `gap` names what. Raised by the server, never a daemon: the daemon that would report it is the one that lacks it.
              - …
            - object: A stored credential no scan will send to a daemon: it is assigned to no site and no host, and no discovery targets it. Raised by the server at the end of a run, never a daemon: the daemon never receives the credential, so it cannot report on it.
              - …
            - object: The port the credential needs was not open, so it was never tried.
              - …
            - object: The credential was refused.
              - …
            - object: The credential is incomplete and could not be used.
              - …
            - object: TLS could not be negotiated.
              - …
            - object: Something answered that is not the expected service.
              - …
            - object: Authenticated, then failed while collecting.
              - …
            - object: Authenticated, then ran out of time while collecting.
              - …
            - object: Nothing was reachable at the address.
              - …
            - object: The attempt timed out before anything answered.
              - …
            - object: The scan found a service an integration reads, on a host no credential of that integration is assigned to. Raised by the server at the end of a run, never a daemon: coverage is a fact about stored assignments, which the daemon does not hold. One per host and integration; the UI groups them.
              - …
            - object: A running guest at addresses no subnet this scan covers, on a shared network or one the node's config doesn't list, so it was recorded without ports or services.
              - …
            - object: A running guest on a bridge with no uplink that only its node holds an address on (a NAT'd `vmbr1`), so only a daemon on that node reaches it.
              - …
            - object: A running guest on a bridge with no uplink and no node address, so only a daemon on a guest attached to that bridge reaches it.
              - …
            - object: Addresses on a routed subnet completed a TCP handshake and then answered nothing, so no host was recorded at any of them. The shape a middlebox in the path produces: a firewall session helper, a load balancer or a scrubbing appliance answering on behalf of addresses with nothing behind them. One warning per subnet rather than per address — the case that raises it raises it for every address in the range, and 254 copies of a sentence is not 254 findings.
              - …
            - object: The run hit its global time limit, with an estimate of the work left.
              - …
            - object: The run hit its global time limit, with no usable estimate.
              - …
            - object: The PROFINET DCP sweep did not report back within its budget, so the run finished without the devices only it would have found.
              - …
            - object: The ICMP sweep did not report back within its budget, so hosts that answer only to ping were not scanned.
              - …
            - object: Reverse DNS lookups that got no answer in time, so those hosts have no DNS hostname.
              - …
            - object: The advertised identifier matches no host on this site.
              - …
            - object: The advertised identifier matches several hosts, so none can be picked.
              - …
            - object: The far end resolved, and its port id is of a subtype there is no lookup for.
              - …
            - object: The far end resolved, and none of its ports matches the advertised port id.
              - …
            - object: The far end resolved, and several of its ports match, so it identifies none.
              - …
            - object: A range was created from the addresses unplaceable far ends publish for themselves.
              - …
            - object: A host is known only from another device's report and awaits confirmation.
              - …
            - object: Link resolution ran out of its time budget, so this scan's links are incomplete. The pass runs on the completion request, which the daemon will abandon at its own timeout — so it is bounded, and stopping has to be *said* rather than logged. Without this the degraded result is indistinguishable from a network that genuinely has no neighbours.
              - …
            - object: FDB link resolution ran out of its time budget, so this scan's FDB-derived links are incomplete. Distinct from `NeighbourResolutionIncomplete`: that pass runs first and this one is strictly sequenced after it, so a reader needs to know which pass stopped to judge how much of Physical Topology is affected.
              - …
            - object: The daemon submitted this scan in a wire format a current daemon no longer produces. Server-side finding, raised where the raw request body is read. The server translates the old format, so nothing was lost — this says the translation happened, which is the only evidence an operator ever gets that a daemon is behind. Which superseded format it was is deliberately not carried, and neither is the fact that the translation worked: the reader needs one thing from this, which is that the daemon is out of date. The daemon to upgrade is the session's own — the UI reads `daemon_id` off the payload this rides on rather than repeating it here, which is what lets the row deep-link to that daemon's upgrade modal. Ends with the compatibility window. When the enforced floor rises past the last release that sends an old format, the translations go and this goes with them.
              - …
            - object: The run produced more warnings than the scan record holds. Emitted rather than dropping the tail silently — a list that simply stops reads as though that was all of them.
              - …
            - object: A warning this binary does not recognise: a bare string from a historical record or a pre-coded daemon, or a code from a newer one. Carries the original text so scan history keeps rendering; the code itself is what reaches the metric, never `detail`.
              - …
      - `type` 'Historical', required
    - object
      - `last_run` string, date-time, nullable: When the scan last ran.
      - `type` 'AdHoc', required
  - `site_id` string, uuid, required: The site this entity belongs to.
  - `tags` string[], required: Tags assigned to this entity.
  - `created_at` string, date-time, required: When this record was first created.
  - `force_full_scan` boolean: When true, the next scan will be a full port scan regardless of interval
  - `id` string, uuid, required: Server-assigned unique identifier.
  - `integration_targets` IntegrationTarget[], required: Per-daemon integration targeting: which integrations run on this daemon, and on which IPs. Delivered via the init command at registration and editable via the discovery modal. This is the single home for cred↔IP targeting; it replaces the global `credential.target_ips` (race-prone, consumed once). One-shot: a target is offered to the daemon until a scan completes successfully, then dropped by [`Discovery::apply_successful_scan`]. Credentials that earned a durable home during the scan keep being retried from there — `host_credentials` for one that probed successfully, `site_credentials` for a broadcast one (see [`Discovery::take_site_scope_credential_ids`]).
    - union: Per-daemon integration targeting, stored on the `Discovery` entity and delivered via the init command at registration. Each entry references exactly one stored credential and says where it applies on this daemon. This is the single home for cred↔IP targeting — it replaces the global, race-prone `credential.target_ips`. The variants ARE the scopes; their strum [`Target`] discriminants are the capability enum that `CredentialType::targets()` returns and validates against (single source of truth). Every target carries a real `credential_id` — there is no credential-less branch and no nil sentinel; a local socket is just a credential whose type targets only the daemon host.
      - object: The daemon's own host — realized as a 127.0.0.1 IP-override (e.g. a local Docker/Podman socket, or any credential the user pins to the daemon host without naming its IP).
        - `credential_id` string, uuid, required: Credential to use on the daemon host.
        - `scope` 'DaemonHost', required
      - object: All hosts on the site, as a broadcast default credential. Daemons at or below `last_without_site_wire` call this scope `"Network"`; the wire rewrite in `server/shared/legacy.rs` translates it both ways.
        - `credential_id` string, uuid, required: Credential to use across the site.
        - `scope` 'Site', required
      - object: Specific host IPs — one IP-override per address.
        - `credential_id` string, uuid, required: Credential to use on the listed addresses.
        - `ips` string[], required: The host addresses this credential applies to.
        - `scope` 'Hosts', required
  - `next_run_is_full_scan` boolean: Whether the next run of this discovery will be a full scan of all 65,535 ports rather than a light scan of common ports. Computed by the server on read and never stored.
  - `scan_count` integer: Number of completed scans (incremented by server on session completion)
  - `updated_at` string, date-time, required: When this record was last modified.

## Response `200`

Discovery updated successfully

- ApiResponseDiscovery: Envelope for a successful response. Failures are sent with a non-2xx status and an `ApiErrorResponse` body instead.
  - `data` object: The result payload. Omitted on failure.
    - `daemon_id` string, uuid, required: The daemon this entity refers to.
    - `discovery_type` union, required
      - object
        - `host_id` string, uuid, required: The host the daemon is running on.
        - `type` 'SelfReport', required
      - object
        - `host_naming_fallback` 'Ip' | 'BestService', required
        - `snmp_credentials` object: SNMP credentials for querying devices during discovery Server builds this mapping before initiating discovery
        - `subnet_ids` string[], nullable, required: Subnets to sweep. `null` sweeps every subnet on the site.
        - `type` 'Network', required
      - object
        - `host_id` string, uuid, required: The host the daemon is running on.
        - `host_naming_fallback` 'Ip' | 'BestService', required
        - `type` 'Docker', required
      - object: A one-shot verification of known hosts: re-check the addresses and ports already recorded for them, rather than sweeping a subnet. One rescan covers every requested host that the same daemon last found. Created by the server only (never via the API) and deleted once its session reaches a terminal phase, so it is not a discovery configuration anyone owns or sees in their scan list.
        - `host_id` string, uuid, required: ID of the host that the daemon is running on — same meaning as every other variant. The hosts being rescanned are `target_host_ids`.
        - `ips` string[], required: Addresses to scan on those hosts.
        - `ports` PortType[]: Ports already known on those hosts, re-checked on every address to confirm they are still open. Scanned in addition to the standard discovery set, so a rescan also surfaces newly-opened services.
          - `number` integer, required: TCP or UDP port number
          - `protocol` 'Udp' | 'Tcp', required: Transport protocol the port is open on.
          - `type` 'Ssh' | 'Telnet' | 'DnsUdp' | 'DnsTcp' | 'Samba' | 'Nfs' | 'Ftp' | 'Ipp' | 'LdpTcp' | 'LdpUdp' | 'Ldap' | 'Ldaps' | 'Kerberos' | 'Snmp' | 'SnmpAlt' | 'Rdp' | 'Ntp' | 'Sip' | 'SipTls' | 'Rtsp' | 'Dhcp' | 'Http' | 'MySql' | 'PostgreSQL' | 'MongoDB' | 'Redis' | 'MsSql' | 'Docker' | 'DockerTls' | 'Kubernetes' | 'RabbitMqMgmt' | 'Cassandra' | 'Elasticsearch' | 'InfluxDb' | 'CouchDb' | 'Kafka' | 'Http3000' | 'Http5000' | 'Http8080' | 'Http8081' | 'Http8082' | 'Http8888' | 'Http9000' | 'Https' | 'Https8443' | 'Https9443' | 'Https10443' | 'Mqtt' | 'MqttTls' | 'AMQP' | 'AMQPTls' | 'Wireguard' | 'OpenVPN' | 'BACnet' | 'ModbusTcp' | 'OpcUa' | 'EtherNetIp' | 'JetDirect' | 'Custom': Well-known port identifier. Auto-derived from number+protocol, so it is optional on create.
        - `settings` RescanSettings: Scan settings that apply to a single-host rescan. Deliberately narrower than [`ScanSettings`]: a rescan verifies a known host against a known port set, so the full-scan mechanism (`is_full_scan`, `full_scan_interval`) must not be expressible — promoting a rescan to a 65,535-port sweep defeats the feature. The remaining omissions are settings that cannot bind on a one-or-two address target.
          - `arp_retries` integer, nullable: ARP retry rounds. Matters more here than in a sweep: for a rescan, "did it answer" is the entire answer, so a missed round reads as a dead host.
          - `port_scan_batch_size` integer, nullable: Ports scanned concurrently per host.
          - `probe_raw_socket_ports` boolean: Whether to probe raw-socket ports 9100-9107. Correctness-affecting: with this off the scanner drops those ports from its results, so a printer's known JetDirect port would look like it had disappeared.
          - `scan_rate_pps` integer, nullable: Port scan probes per second. Operators lower this for fragile devices or noisy IDS, and a rescan must respect that as much as a discovery does.
          - `trust_port_only_detections` boolean: Carried into a rescan: a rescan of a host on a routed subnet runs the same evidence check, and it would be strange for a host to survive a scan and vanish on rescan, or the reverse.
          - `use_npcap_arp` boolean: On Windows, use Npcap broadcast ARP instead of SendARP.
        - `target_host_ids` string[], required: The hosts being rescanned. Reads the single `target_host_id` that rescans stored before multi-host rescans carry, and that older daemons echo back, as a list of one.
        - `type` 'Rescan', required
      - object
        - `host_id` string, uuid, required: ID of the host that the daemon is running on
        - `host_naming_fallback` 'Ip' | 'BestService', required
        - `scan_settings` ScanSettings: Scan performance settings. Lives on the discovery entity. Numeric fields are `Option<T>` — `None` means "use daemon default". The daemon unwraps with defaults at point of use.
          - `arp_rate_pps` integer, nullable: ARP packets per second (default: 50)
          - `arp_retries` integer, nullable: ARP retry rounds for non-responsive targets (default: 2 = 3 total attempts)
          - `arp_scan_cutoff` integer, nullable: ARP scan cutoff prefix. Interfaced subnets larger than this prefix are truncated to this many IPs. Default: 15 (= /15, ~131K IPs). Lower values scan more IPs — increase arp_rate_pps accordingly.
          - `full_scan_interval` integer, nullable: Run a full 65k port scan every N scans. Other scans use a light port set. Default: 3. Value of 0 means never full scan. Value of 1 means every scan is full.
          - `is_full_scan` boolean: Whether this specific scan run should do a full 65k port scan. Set by the server before dispatching to the daemon — not user-configurable.
          - `max_discovery_duration` integer, nullable: Hard ceiling on how long a single discovery run may take, in seconds (default: 21600 = 6h). When hit, the run force-completes and any hosts still queued are left un-scanned until the next run. Raise this for very large networks that legitimately need more than the default window.
          - `port_scan_batch_size` integer, nullable: Ports scanned concurrently per host (default: 200, clamped 16-1000)
          - `probe_raw_socket_ports` boolean: Whether to probe raw-socket ports 9100-9107 (default: false). Disabled by default to prevent ghost printing on JetDirect printers.
          - `scan_rate_pps` integer, nullable: Port scan probes per second (default: 500)
          - `trust_port_only_detections` boolean: Whether an open TCP port alone is enough to record a host on a subnet the daemon reaches *through* something (default: false). On a subnet the daemon has an interface on, ARP answers first and this never applies. On a routed one every address arrives enumerated, and a middlebox that completes the TCP handshake for addresses with nothing behind them — a FortiGate session helper, a load balancer, a scrubbing appliance — makes every address look open. With this off, such an address is recorded only when something actually answered: a probe that spoke the protocol, an endpoint that replied, a credential that authenticated. Turning it on restores the older behaviour for hosts whose every open port is one Scanopy cannot interrogate. That is a real class of host — a bespoke TCP service on a routed subnet — which is why the escape hatch exists rather than the rule simply being absolute.
          - `use_npcap_arp` boolean: On Windows, use Npcap broadcast ARP instead of SendARP (default: false)
        - `subnet_ids` string[], nullable, required: Subnets to scan. None = scan all interfaced subnets.
        - `type` 'Unified', required
    - `name` string, required: Human-facing name for this discovery.
    - `run_type` union, required
      - object
        - `cron_schedule` string, required: Cron expression deciding when the scan runs.
        - `enabled` boolean, required: Whether the schedule is active.
        - `last_run` string, date-time, nullable: When the scan last ran.
        - `timezone` string, nullable: IANA timezone for cron evaluation, e.g. "America/New_York". None = UTC.
        - `type` 'Scheduled', required
      - object: Historical discovery runs are created by the server and cannot be submitted via API
        - `results` DiscoveryUpdatePayload, required: Progress update from daemon to server during discovery
          - `credential_results` CredentialRunResult[]: What each stored credential did in this run, one entry per credential. Results, not problems: those are `warnings`. Old servers ignore it. Read per entry, like `warnings`: an outcome shape from a newer daemon becomes `Unknown` for that credential instead of failing the terminal payload.
            - `credential_id` string, uuid, required: The credential these results belong to.
            - `outcome` union, required
              - …
          - `daemon_id` string, uuid, required: The daemon this entity refers to.
          - `daemon_version` string, nullable: The daemon's version when the run ended. Stamped by the server at terminal, so it survives the daemon being deleted or upgraded.
          - `discovery_id` string, uuid, nullable: The discovery configuration this session belongs to. Always enriched server-side; daemons do not send this field.
          - `discovery_type` union, required
            - object
              - …
            - object
              - …
            - object
              - …
            - object: A one-shot verification of known hosts: re-check the addresses and ports already recorded for them, rather than sweeping a subnet. One rescan covers every requested host that the same daemon last found. Created by the server only (never via the API) and deleted once its session reaches a terminal phase, so it is not a discovery configuration anyone owns or sees in their scan list.
              - …
            - object
              - …
          - `error` string, nullable: Failure message, when the run did not complete.
          - `estimated_remaining_secs` integer, nullable: Rough estimate of the time left, in seconds.
          - `finished_at` string, date-time, nullable: When the run finished. `null` while it is still going.
          - `hosts_discovered` integer, nullable: Hosts found so far.
          - `last_update_at` string, date-time, nullable: When the server last heard about this run. Stamped by the server at terminal.
          - `phase` 'AwaitingSnapshot' | 'Queued' | 'Pending' | 'Starting' | 'Started' | 'Scanning' | 'Complete' | 'Failed' | 'Cancelled', required
          - `progress` integer, required: Completion of the current phase, from 0 to 1.
          - `reason` 'Completed' | 'UserCancelled' | 'DaemonReportedFailure' | 'StalledNoUpdates' | 'DaemonRestarted' | 'DaemonUnreachable' | 'DaemonBusy' | 'WatchdogTimeout' | 'DaemonOffline' | 'SessionSilent' | 'DaemonDroppedSession': Why a session reached its terminal phase. `phase` says *that* a run ended; this says *why*. A stall reaped by the server, a daemon that lost its state in a restart and a user pressing cancel all used to land as the same phase with no way to tell them apart afterwards, which is what made stall reports undiagnosable. The server decides every reason except the two the daemon alone can know, see [`Self::daemon_assigned`]. Absent on runs recorded before this field existed.
          - `scanned` ScannedEntityIds: Canonical IDs of entities scanned in a discovery session. Populated daemon-side at terminal phase from `EntityBuffer`'s `Created` entries. Travels with the terminal `DiscoveryUpdatePayload` to the server, rides the in-memory `EntityOperation::Created` event published for the historical Discovery row (the event scope carries `Entity::Discovery` with the full struct, including `run_type::Historical { results }`), then is stripped before persisting into the historical Discovery row's JSONB (see the `SqlValue::RunType` bind_value handler in `backend/src/server/shared/storage/generic.rs`). Per-entity-service subscribers extract `results.scanned` from the in-memory event and call `DiscoveryFkUpdater::update_discovery_fks` to backfill `last_discovery_id` / `first_discovery_id` on the matched rows. Naming: `scanned_*` because the daemon scans entities — some submissions match existing rows (refresh), others insert new rows. Both populate the EntityBuffer with canonical (server-assigned) IDs.
            - `binding_ids` string[]: Service bindings touched by this discovery.
            - `found_subnet_ids` string[]: Subnets this discovery found without sweeping: ranges riding in a host request, ranges the server inferred or placed addresses into, and ranges holding an address the scan saw. Filled by the server, never by a daemon. Kept apart from `subnet_ids` because the digest reads that list as the ranges the scan *swept*, and counting a range it only found evidence in would report every unobserved host there as stale. The discovery FKs take both.
            - `host_ids` string[]: Hosts touched by this discovery.
            - `interface_ids` string[]: Interfaces touched by this discovery.
            - `ip_address_ids` string[]: IP addresses touched by this discovery.
            - `port_ids` string[]: Ports touched by this discovery.
            - `service_ids` string[]: Services touched by this discovery.
            - `subnet_ids` string[]: Subnets touched by this discovery.
            - `vlan_ids` string[]: VLANs touched by this discovery.
          - `session_id` string, uuid, required: The discovery run this update belongs to.
          - `site_id` string, uuid, required: The site this entity belongs to.
          - `started_at` string, date-time, nullable: When the run started.
          - `warnings` DiscoveryWarning[]: Non-fatal findings from a completed run — one per occurrence, each carrying the code that identifies it and the detail that fills the sentence. Unlike `error`, these do not mark the run failed. Read through [`deserialize_warnings`] rather than the derived impl, which is what keeps historical records and pre-coded daemons rendering: both send bare strings here, and both land as `Unknown` carrying that text instead of failing the whole payload.
            - union: A single non-fatal finding from one discovery run, about one device, neighbour, or the scan itself. Serialized with the code as the tag, so the generated TypeScript is a discriminated union the UI can switch on exhaustively. The derived `Deserialize` reads that shape; the leniency that keeps historical records and pre-coded daemons working lives in [`deserialize_warnings`], which is applied at the one field that holds these.
              - …
        - `type` 'Historical', required
      - object
        - `last_run` string, date-time, nullable: When the scan last ran.
        - `type` 'AdHoc', required
    - `site_id` string, uuid, required: The site this entity belongs to.
    - `tags` string[], required: Tags assigned to this entity.
    - `created_at` string, date-time, required: When this record was first created.
    - `force_full_scan` boolean: When true, the next scan will be a full port scan regardless of interval
    - `id` string, uuid, required: Server-assigned unique identifier.
    - `integration_targets` IntegrationTarget[], required: Per-daemon integration targeting: which integrations run on this daemon, and on which IPs. Delivered via the init command at registration and editable via the discovery modal. This is the single home for cred↔IP targeting; it replaces the global `credential.target_ips` (race-prone, consumed once). One-shot: a target is offered to the daemon until a scan completes successfully, then dropped by [`Discovery::apply_successful_scan`]. Credentials that earned a durable home during the scan keep being retried from there — `host_credentials` for one that probed successfully, `site_credentials` for a broadcast one (see [`Discovery::take_site_scope_credential_ids`]).
      - union: Per-daemon integration targeting, stored on the `Discovery` entity and delivered via the init command at registration. Each entry references exactly one stored credential and says where it applies on this daemon. This is the single home for cred↔IP targeting — it replaces the global, race-prone `credential.target_ips`. The variants ARE the scopes; their strum [`Target`] discriminants are the capability enum that `CredentialType::targets()` returns and validates against (single source of truth). Every target carries a real `credential_id` — there is no credential-less branch and no nil sentinel; a local socket is just a credential whose type targets only the daemon host.
        - object: The daemon's own host — realized as a 127.0.0.1 IP-override (e.g. a local Docker/Podman socket, or any credential the user pins to the daemon host without naming its IP).
          - `credential_id` string, uuid, required: Credential to use on the daemon host.
          - `scope` 'DaemonHost', required
        - object: All hosts on the site, as a broadcast default credential. Daemons at or below `last_without_site_wire` call this scope `"Network"`; the wire rewrite in `server/shared/legacy.rs` translates it both ways.
          - `credential_id` string, uuid, required: Credential to use across the site.
          - `scope` 'Site', required
        - object: Specific host IPs — one IP-override per address.
          - `credential_id` string, uuid, required: Credential to use on the listed addresses.
          - `ips` string[], required: The host addresses this credential applies to.
          - `scope` 'Hosts', required
    - `next_run_is_full_scan` boolean: Whether the next run of this discovery will be a full scan of all 65,535 ports rather than a light scan of common ports. Computed by the server on read and never stored.
    - `scan_count` integer: Number of completed scans (incremented by server on session completion)
    - `updated_at` string, date-time, required: When this record was last modified.
  - `error` string, nullable: Not sent on a successful response. Failure messages arrive in an `ApiErrorResponse`.
  - `meta` ApiMeta, required: API metadata included in all responses
    - `api_version` integer, required: API version (integer, increments on breaking changes)
    - `server_version` string, required: Server version (semver)
  - `success` boolean, required: Always `true` on a successful response.

## Other responses

- `400`: Can't update historical discovery

## Changes

- **2026-10-31** `c94279effef3`: 1 breaking, 1 info
  - added `subschema #56` to the `data/allOf[#/components/schemas/DiscoveryBase]/run_type/oneOf[subschema #2: Historical]/results/warnings/items/` response property `oneOf` list for the response status `200`
  - added `subschema #56` to the `allOf[#/components/schemas/DiscoveryBase]/run_type/oneOf[subschema #2: Historical]/results/warnings/items/` request property `oneOf` list
- …earlier changes not shown

[Operation history](https://skmtc.dev/scanopy/apis/scanopy-api/changes/api/v1/discoveries/:id/put.md)

---

[API](https://skmtc.dev/scanopy/apis/scanopy-api.md) - [All operations](https://skmtc.dev/scanopy/apis/scanopy-api/llms.txt) - [OpenAPI document](https://skmtc.dev/scanopy/apis/scanopy-api/revisions/966ba786bdb5?raw)
