
API Connector
Outline
Objective
This guide shows you how to send asset and security-finding data from your own systems into SAFE using the API Connector, so that SAFE can use it for exposure prioritization.
Typical sources are:
- A security data lake such as Databricks, Snowflake or Redshift
- A CMDB or asset inventory you maintain
- Exports from security tools, such as SentinelOne, Tenable or ServiceNow
By the end of this guide you will have:
- Created API credentials in SAFE and obtained an access token.
- Exported your data as compressed NDJSON files.
- Requested secure upload URLs and uploaded your files to SAFE.
- Confirmed that the data appears on the Assets and Findings pages, labelled with your source name.
Target Users
This document is intended for Engineers and Architects who wish to understand how to post security findings to SAFE. You do not need prior SAFE API experience, but you should be comfortable running curl commands or a short script.
- SAFE Administrator: Creates the API credentials in SAFE and confirms the data after upload.
- Security or Data Engineer: Ingests data from the data lake, builds the NDJSON files, and automates the push.
Prerequisites
On the SAFE side
- SAFE Admin Access
- SAFE API base URL: The URL you use to sign in, for example https://{your-tenant}.safescore.ai
- IP address is allowed: If your tenant restricts access by IP, the machine that calls the SAFE APIs must be on the allowed list
On the Source side
- Read access to data
- A way to export to NDJSON: A query plus a script, notebook or ETL job that writes one JSON object per line. Any language that can write JSON and gzip files works.
- A place to run the push: A server, container or scheduled job with
curlor an HTTP client. It must reach your SAFE URL over HTTPS and the upload URLs SAFE returns, which are hosted on Amazon S3 - A secrets manager: A place to store the API username and password.
- A scheduler (recommended): Pushes should repeat, typically every 24 hours. Use your existing orchestrator or cron.
Data requirements
For every asset you send, make sure your data lake (source) can provide:
- A source name. The name of the tool the data came from, for example
Qualys. It is case-sensitive and appears in SAFE as the asset's source, so keep it identical on every push. - At least one identity field. One of
cloudResourceId,hostname,fqdn,netbiosName, a MAC address or an IPv4 address. SAFE uses these to match the record to assets it already knows. - A complete picture of the asset for that source. Each push replaces what that source previously reported for the asset: findings, software, patches and tags missing from the new record are marked as resolved. Plan to send the full current list every time, not only changes.
- Open vulnerabilities only. A vulnerability left out of the next push is closed for that source.
- Everything else (hardware, cloud details, owners, software, patches, compliance results) is optional. Send what you have; more context gives SAFE better prioritization.
Limitations
| Limit | Value |
|---|---|
| Files per upload URL request | 100 |
| Maximum file size (compressed) | 250 MB |
| Access token lifetime | 60 minutes |
| Upload URL lifetime | Just under 1 hour (see expiresAt in the response) |
| Upload URLs per tenant | 1,000 per minute, shared by every client of your tenant |
| Size of one line (one asset record, uncompressed) | 128 MB |
| Findings per asset | 20,000. An asset with more in one record is not imported. |
How it works
You export your data as compressed NDJSON files, ask SAFE for upload URLs, and upload the files over HTTPS. SAFE then matches each asset to the assets it already knows, merges the records, and shows the data on the Assets and Findings pages, labelled with the source name you sent.
| Step | What you do | What SAFE does |
|---|---|---|
| 1. Authenticate | Call POST /api/v3/auth with your API credentials. |
Returns an access token, valid for 60 minutes. |
| 2. Prepare files | Write your asset records as NDJSON, gzip each file, and note each file's size in bytes. | Nothing yet. |
| 3. Request upload URLs | Call POST /api/v3/integrations/file-ingest/uploads with the size of each file. |
Returns one secure, time-limited upload URL per file. |
| 4. Upload | Upload each file to its URL with an HTTP PUT. |
Stores the file in your SAFE storage. A 200 OK confirms SAFE has it. |
| 5. Review | Open the Assets and Findings pages. | Processes the files in the background and shows the data. |
Step-by-step procedure
Authenticate
Create API credentials
Skip this if you already have a credential for this feed.
- In SAFE, go to Settings > API Credentials and select +.
- Enter a name (for example
data-lake-uploader), choose an expiry and select Add. - Copy the API Username and Password that SAFE shows and store them in your secrets manager. The password is shown only at creation. The new credential then appears under Settings > API Credentials.
Get an access token
Exchange the credentials for an access token with HTTP Basic authentication:
curl -X POST "https://{your-tenant}.safescore.ai/api/v3/auth" \
-u "$SAFE_API_USERNAME:$SAFE_API_PASSWORD"
The response contains accessToken, save it.
- A token is valid for 60 minutes. Reuse it for every call in that time instead of authenticating before each call.
- If a SAFE API call returns
401, the token has expired or is invalid. Authenticate again and retry the call once.
Prepare your files
File format
- NDJSON (newline-delimited JSON), UTF-8. Write one asset per line as
{"signals":[ {one signal} ]}. Each line is a complete JSON object and lines are separated by\n. - Do not wrap the lines in an array. Do not put more than one signal in a line's
signalsarray: such a line is skipped. - gzip-compress every file. The upload URL is signed for
application/gzip. Any local file name works;*.ndjson.gzis a useful convention. - Send each asset once per file, and never split one asset across lines or files.
- Put many assets in each file. Fewer, larger files are better than many small ones. Never send one asset per file.
Example
You only need the fields below to get started. In your file this record is written on one line; it is formatted here for reading.
{
"signals": [
{
"version": "2.0.0",
"id": "6f1d2c3b-8a4e-4f5a-9b7c-0d1e2f3a4b5c",
"type": "default",
"entity": {
"source": "Qualys",
"lastSeen": "2026-10-16T18:00:00Z",
"entityAttributes": {
"hostname": "WEB-PROD-01",
"networkInterface": [
{ "ipv4": "10.20.30.40", "macAddress": "0a:1b:2c:3d:4e:5f", "isPrimary": true }
]
}
},
"securityContexts": [
{
"type": "va",
"name": "CVE-2024-6387",
"severity": "high",
"status": { "complianceStatus": "fail" }
}
]
}
]
}
Record structure
Each line is one signal. version, id, type and entity.source are required, plus at least one identity field. Every other section is optional: send only the sections and fields you have.
| Section | What it holds | Required inside each item |
|---|---|---|
version, id, type |
The envelope: always "2.0.0", your UUID for the line, always "default" |
All three |
entity |
source, sourceAssetId, firstSeen, lastSeen and entityAttributes |
source |
entity.entityAttributes |
The device: names, network interfaces, type, tags, platform, hardware and OS, cloud details, location, owners | At least one identity field (see below) |
securityContexts[] |
Findings: vulnerabilities (type: "va") and configuration control results (type: "ca") |
type, name, status.complianceStatus; a ca also needs controlId and policyId or policyName |
softwares[] |
Installed software and agents (not the operating system) | name |
patches[] |
Patches (KB numbers) | patchNumber, status |
compensatoryControls[] |
Security-tool configuration evidence (EDR …), one block per type. Send it in the security tool's own files, for example under source SentinelOne |
type, and at least one control with controlId, controlName and value |
Every field, with its format and meaning, is listed in Field reference below.
Optional sections add context: hardware and OS, cloud details, owners, softwares[], patches[] and compensatoryControls[]. Send only what you have. Keys SAFE does not recognise are ignored.
Asset identity: how SAFE recognises a device
Every signal needs at least one identity field: cloudResourceId, hostname / fqdn / netbiosName, networkInterface[].macAddress or networkInterface[].ipv4. SAFE treats two records as the same asset at the first rule that matches:
Cloud resource ID is the same (
cloudResourceId). Always send it for cloud resources.Host name is the same (
hostname,fqdnornetbiosName; not case-sensitive;WEB01matchesWEB01.corp.example.com), and the serial numbers do not differ.MAC address is the same, when one of the two records has no host name. Only the primary interface's IP and MAC (
isPrimary, else the first valid one) are used for matching; other interfaces are shown, not matched.IP address is the same, when one of the two records has no host name. The weakest rule: IPs change and public IPs can be shared (NAT), so send private addresses where you can.
Host name and FQDN together. When fqdn contains a dot, SAFE keeps hostname and netbiosName only if they are the same as fqdn; otherwise it keeps the FQDN alone. Matching is not affected (a short name still matches its FQDN), but the short names are not shown. If you have an FQDN, send it in fqdn; send hostname alone only when there is no FQDN.
Skipped records. A record with no usable identity is skipped. Placeholders do not count: 0.0.0.0, 127.0.0.1, 169.254.x.x, IPv6 addresses, 00:00:00:00:00:00, and serials such as N/A. A virtual-machine MAC address (VMware, VirtualBox, Docker and similar) is stored but never used to match. A generic host name such as LINUX, UBUNTU or IPHONE needs a MAC address with it, or the whole record is skipped.
The full-snapshot rule
For each asset and source in a push, SAFE treats the record as the complete, current picture:
Items in the new signal are added or updated.
Items SAFE had from that source for that asset, but that are missing from the new signal, are marked as gone (resolved, not deleted). This applies to findings, software, patches, asset tags, location, and compensatory controls (when compensatory-control assessment is enabled for your tenant).
Assets that are not in the push are not changed.
Data from other sources on the same asset is not changed.
Always send the full list of findings, software, patches and compensatory controls for an asset, not only what changed since the last push. Send open vulnerabilities only: a vulnerability missing from the source's next signal for that asset is closed.
Source
entity.sourcenames the tool the data came from, for exampleQualys. It is required in every line. A file may contain lines from several sources; each line is processed under its own source.Any name is accepted, exactly as you send it. Names are case-sensitive:
Qualysandqualysare two different sources. Keep them consistent between pushes.A name may be up to 255 characters, must not be only digits, and must not be
manual.If two tools report the same asset, send each tool's data under its own source name, in its own files or as its own group of rows. SAFE merges them into one asset.
Values
| Kind | Format |
|---|---|
| Dates and times | ISO 8601 with a time zone, for example 2026-09-30T18:00:00Z or 2026-09-30T23:30:00+05:30. Always include the zone: a time without one, or a date alone, is read as UTC (a date as midnight), which shifts a local time. |
| Numbers | Integers, for example "cpuCores": 40. A numeric string or a decimal is also accepted; decimals are truncated. |
| JSON types | Send each value in the type the field reference gives. A number or true/false in a text field is kept as text; an object or array in a single-value field drops only that field. Identity fields (hostname, fqdn, netbiosName, ipv4, macAddress, cloudResourceId), finding names, envelope fields, severity and timestamps must be JSON strings, or the field is dropped. A wrong structure, such as hardware or status sent as a string, skips the whole line. |
| Finding type | va (vulnerability) \ |
| Finding status | status.complianceStatus: fail \ |
| Finding severity | severity (a plain string, for example "severity": "high"): critical \ |
| Software type | application \ |
| Patch status | INSTALLED \ |
| Location | An array with one full country name, for example ["United States"] (a single string is also accepted). Use the same spelling every time. |
| Owners | One string per owner field; a list is accepted and joined with ", ". |
| Keys | camelCase, exactly as in the field reference. Unknown keys are ignored and the line is still processed. (Unknown fields in the upload URL request are rejected with 400.) |
Field reference
Every key is optional unless the Required column says otherwise. Keys not listed here are ignored.
Envelope and entity
| Field | Required | Format / allowed values | Meaning and what SAFE does |
|---|---|---|---|
version |
Yes | "2.0.0" exactly | Contract version of the record. Any other value skips the whole line. |
id |
Yes | UUID (any version) | Your own unique id for this line. Must be a valid UUID; a line with an invalid id is skipped. Not stored: SAFE assigns its own asset id. |
type |
Yes | "default" exactly | Record type. Any other value skips the line. |
entity.source |
Yes | String, 1–255 bytes (spaces trimmed); not digits only; not manual in any case. Case-sensitive. |
Name of the tool or system that reported the asset, for example Qualys. Shown as the asset's source. A finding this source reported before and is missing from its newest signal for the asset is closed. |
entity.sourceAssetId |
No | String | The asset's id inside the source tool. Kept by SAFE for traceability; not shown. |
entity.firstSeen |
No | ISO 8601 timestamp | When the source first saw the asset. Shown as the source's first-seen date; set when SAFE first records the asset for this source and not moved by later files. |
entity.lastSeen |
No | ISO 8601 timestamp | When the source last saw the asset. Shown as the source's last-seen date; updated on every push. |
Identity, network, location and owners
| Field | Required | Format / allowed values | Meaning and what SAFE does |
|---|---|---|---|
hostname |
One of the identity fields | String (must be a JSON string); shown uppercased | Short host name. Identity key; shown in Network Interfaces. See Host name and FQDN together. A name that only wraps an IP (for example ip-10-1-2-3) is not used as a host name; SAFE uses the IP instead. |
fqdn |
One of the identity fields | String (must be a JSON string), for example web-prod-01.corp.example.com; shown uppercased | Fully qualified DNS name. Identity key; shown in Network Interfaces; used for the asset name when there is no cloud name. |
netbiosName |
One of the identity fields | String (must be a JSON string), up to 15 characters; shown uppercased | Windows NetBIOS name. Identity key; shown in Network Interfaces. |
networkInterface[].ipv4 |
One of the identity fields | Dotted IPv4 string. 0.0.0.0, 127.0.0.1 and 169.254.x.x are dropped; IPv6 is not accepted. Public addresses are accepted, but send private addresses where you can: a shared public IP can match unrelated devices. | IPv4 address of one interface. Identity key; shown in Network Interfaces (newest first). |
networkInterface[].macAddress |
One of the identity fields | String: aa:bb:cc:dd:ee:ff, aa-bb-…, aabb.ccdd.eeff or 12 hex digits; stored lowercase. 00:00:00:00:00:00 is dropped. | MAC address of the same interface. Identity key; shown in Network Interfaces. |
networkInterface[].isPrimary |
No | true \ | false ("yes"/"no" and 1/0 are also accepted). The first true wins; without one, the first valid interface is primary. |
sourceAssetType |
No | String | Asset type as the source names it, for example Server. Shown as sent; SAFE derives its own asset category separately. |
sourceAssetSubtype |
No | String | Asset subtype as the source names it, for example Web Server. Shown as sent. |
assetTags[].value |
No | Non-empty string; tags made only of special characters are dropped | A free-form tag. Shown as an asset tag; filterable. |
platform |
No | windows \ | linux \ |
location |
No | Array with one full country name, for example ["United States"] (a single string is also accepted). Only the first value is used; shown uppercased. Use the same spelling every time. | Country the asset is in. Shown as the asset's location; filterable. |
businessOwner, itOwner, vmOwner, deviceOwner |
No | One string each; a list is accepted and joined with ", " | Owners of the asset (name or email). Shown on the asset. When several sources report an owner, SAFE shows one source's value. |
Hardware and operating system
| Field | Required | Format / allowed values | Meaning and what SAFE does |
|---|---|---|---|
osName, osVersion, osVendor |
No | String | Operating system name, version and publisher. Shown under Hardware and as the OS software row. SAFE normalises them (for example "Microsoft Windows Server 2019 Datacenter" may show as "Windows Server 2019"). |
osArch |
No | String, for example x64 | OS architecture. Shown under Hardware. |
osReleaseInstallDate |
No | ISO 8601 timestamp | When the current OS release was installed. Shown under Hardware. |
osInstallTime |
No | ISO 8601 timestamp | When the OS was first installed. Shown as the install date of the OS software row. |
osKernel |
No | String, for example 10.0.17763.5329 | Kernel or full build number. Not shown; SAFE uses it to work out the Windows build when osVersion has none, which drives patch state and CVE closure. |
serialNumber |
No | String; placeholders such as N/A or "To be filled by O.E.M." are ignored | Hardware serial number. Shown under Hardware. Two different serial numbers are never merged. A serial number cannot be an asset's only identity field; sent with an IP or MAC address, a matching serial can link two records. |
systemManufacturer, systemModel |
No | String | Hardware maker and model. Shown under Hardware; used to recognise virtual machines and device types. |
cpuModelName |
No | String | CPU model. Shown under Hardware. |
cpuCores, totalPhysicalMemMB |
No | Integer (a numeric string or a decimal is accepted; decimals are truncated) | Number of CPU cores and RAM in megabytes. Shown under Hardware. |
biosVendor, biosVersion |
No | String | BIOS vendor and version. Shown under Hardware. |
biosReleaseDate |
No | ISO 8601 timestamp | BIOS release date. Shown under Hardware. |
Send the operating system in hardware, not in softwares. Send agents as softwares.
Cloud
| Field | Required | Format / allowed values | Meaning and what SAFE does |
|---|---|---|---|
cloudResourceId |
One of the identity fields | String (must be a JSON string): AWS ARN, Azure resource ID or GCP URI, at most 512 characters; stored lowercase. A placeholder such as -, 0, n/a, none or null counts as no id. | The cloud's unique id for the resource. Strongest identity key; marks the asset as a cloud asset. Without a valid id, the other cloud fields are ignored. |
cloudServiceProvider |
No | AWS \ | Azure \ |
cloudRegion |
No | String, for example us-east-1 | Cloud region. Shown under Cloud. |
cloudAccountId |
No | String (a number is accepted) | Cloud account, subscription or project id. Shown under Cloud. |
cloudNativeAssetType |
No | String, for example AWS::EC2::Instance | The provider's own resource type. Shown under Cloud; drives cloud categorisation. |
cloudAssetType |
No | String, for example ec2-instances | Resource type in plain words. Shown under Cloud. |
cloudInstanceId |
No | String, for example i-0abc123 | Instance id. Shown under Cloud. |
cloudAssetName |
No | String | Resource name in the cloud. Shown under Cloud; becomes the asset's display name. |
cloudVpcId, cloudSubnetId, cloudResourceGroupName |
No | String | VPC, subnet and resource group. Shown under Cloud. |
containerInfo[].containerName |
Yes, per item | Non-empty string | Name of a container running on the resource. The number of distinct names is the container count. |
containerInfo[].containerCount |
No | Integer (a numeric string is accepted) | Running instances of that container. Shown with the image attributes. |
imageAttributes.registry, namespace, name, imageTags, digest |
No | String | The container image the resource runs. Shown under image attributes. |
Vulnerabilities
| Field | Required | Format / allowed values | Meaning and what SAFE does |
|---|---|---|---|
type |
Yes | "va" | Marks the finding as a vulnerability. |
name |
Yes | For a CVE, exactly one bare CVE id, for example CVE-2023-44487 (one finding per CVE). Otherwise the title. | The vulnerability. For a known CVE, SAFE adds the NVD description, CVSS and EPSS. |
status.complianceStatus |
Yes | fail \ | pass |
severity |
No | Plain string: critical \ |
high \ |
sourceFindingType, sourceFindingSubtype |
No | String | Your source's own category and sub-category. Shown as sent. |
description.technical |
No | Text | Technical description. Shown; for a known CVE the NVD text replaces it. |
openSinceInSource, lastAssessed |
No | ISO 8601 timestamp | When the source first found it and last checked it. Shown. |
remediation.description |
No | Text | How to fix it. Shown as remediation. |
threatTags |
No | Array of strings | Threat tags from the source. Used for CVEs. Recognised tags (exact text, case-sensitive): CISA Known Exploit (marks the CVE as CISA KEV), Known Exploit, Ransomware Linked, Malware Linked, Exploit Kit Available. Other tags are kept but do not affect scoring. |
affectedSoftware.name, version, vendorName, type, installPath, installDate |
name, when affectedSoftware is sent |
As in softwares[]; do not use an OS type. |
The software the vulnerability is in. Use the same values as the matching softwares[] entry so SAFE links the two; any difference in name, version or install path creates a second software row. |
Configuration controls
| Field | Required | Format / allowed values | Meaning and what SAFE does |
|---|---|---|---|
type |
Yes | "ca" | Marks the finding as a configuration control result. |
name |
Yes | Text | The control's statement. Shown; replaced by the CIS title when SAFE recognises the control. |
controlId |
Yes | String, for example 18.9.47.4.1 | The control's id in its benchmark. Shown as the control id and used to look the control up in SAFE's CIS library. |
policyId, policyName |
At least one | String | The policy the control belongs to. Controls with the same policyId and policyName are grouped into one policy. For CIS policies SAFE generates the policy id from the name. |
complianceFramework |
No | String, for example CIS | Framework. Shown. |
status.complianceStatus |
Yes | fail \ | pass |
severity |
No | Plain string: critical \ |
high \ |
sourceFindingType, sourceFindingSubtype |
No | String | Your source's own category and sub-category. Shown as sent. |
description.technical, remediation.description |
No | Text | Why the control matters and how to pass it. Shown; the CIS text replaces them when SAFE recognises the control. |
openSinceInSource, lastAssessed |
No | ISO 8601 timestamp | When the source first and last evaluated the control. Shown. |
Software, patches and compensatory controls
| Field | Required | Format / allowed values | Meaning and what SAFE does |
|---|---|---|---|
softwares[].name |
Yes | Non-empty string | Product name. Shown in the software inventory; SAFE may normalise it. |
softwares[].vendorName, version, installPath, installDate |
No | String; installDate is an ISO 8601 timestamp | Vendor, version, install location and install time. Shown. Name, version and install path identify the software row. |
softwares[].type |
No | application \ |
cpe |
softwares[].kernel |
No | String | Kernel the software is built for. Not shown; improves SAFE's product matching. |
patches[].patchNumber |
Yes | String, for example KB5034439 (a number is kept as text) | Patch or KB id. Not listed in the UI; SAFE uses patches to compute software patch state and to close the CVEs a patch fixes. |
patches[].status |
Yes | INSTALLED \ | PENDING \ |
patches[].installDate |
Recommended for an INSTALLED patch | ISO 8601 timestamp | When it was installed. Without a date, an installed patch is kept but cannot close any CVE. |
compensatoryControls[].type |
Yes | String, for example EDR; one block per type | Kind of protection. |
compensatoryControls[].sourceTool, siteId, siteName, groupId, groupName, agentVersion, agentLastActive |
No | String; ids may be numbers; agentLastActive is an ISO 8601 timestamp | The product enforcing the controls, its site and group, and the agent's version and last check-in. Stored with the asset's compensatory control assessment. |
compensatoryControls[].controls[].controlId, controlName, value |
Yes | Text; a number or true/false is kept as text | Each configured setting. Used to assess whether the control reduces the risk of a finding (SentinelOne EDR today). |
Notes:
Each upload URL is signed for one exact file size. So always:
Write the NDJSON and gzip it to a file.
Read the file's size in bytes from disk (for example
os.path.getsize()orstat).Do not re-compress, re-encode or otherwise change a file after measuring it. gzip output changes with the tool, level and timestamp, so a re-compressed file has a different size and S3 refuses the upload.
Request upload URLs
Ask for one upload URL per file, listing the files' sizes in the order you will upload them:
curl -X POST "https://{your-tenant}.safescore.ai/api/v3/integrations/file-ingest/uploads" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"files": [{"sizeBytes": 152340}, {"sizeBytes": 98211}]}'
| Rule | Limit |
|---|---|
| Body | Required. Exactly {"files": [{"sizeBytes": N}, ...]}; any other field is rejected (400). |
| Files per call | 1 to 100 |
sizeBytes |
Whole number, the exact size in bytes of the gzipped file, from 1 up to the maximum file size |
| Upload URLs per tenant | Limited per minute; see Limits |
A successful call returns 201:
{
"success": true,
"data": {
"expiresAt": "2026-10-13T12:59:00Z",
"uploads": [
{
"uploadId": "0b6e…",
"url": "https://….s3.….amazonaws.com/…",
"method": "PUT",
"headers": {
"If-None-Match": "*",
"Content-Type": "application/gzip",
"Content-Length": "152340"
}
}
]
}
}
uploadsis in the same order asfiles:uploads[0]is for the first file you listed.Each upload has an
uploadId. Log it with your file name: SAFE support uses it to trace a file. Do not send it back.expiresAtis when the URLs stop working, currently just under one hour after the call. Use this value rather than assuming a fixed duration.Request URLs only when the files are ready, and only for as many files as you can upload before
expiresAt.
Upload the files
Upload each file with an HTTP PUT to its url, sending exactly the headers listed for it and the file as the raw request body:
curl -X PUT "$UPLOAD_URL" \
-H "If-None-Match: *" \
-H "Content-Type: application/gzip" \
--data-binary @assets-part-1.ndjson.gz
| S3 response | Meaning | What to do |
|---|---|---|
200 OK |
SAFE has the file. | Done. Processing starts automatically. |
412 Precondition Failed |
A file was already uploaded to this URL. | Nothing; the first upload stands. A file cannot be overwritten. |
403 SignatureDoesNotMatch |
The body size differs from sizeBytes, a listed header is missing or different, or the upload used chunked transfer. |
Fix the request. If the file changed after you measured it, request a new URL for it. |
403 AccessDenied with message Request has expired |
The URL is past expiresAt. |
Request a new URL for that file and upload again. |
403 AccessDenied (any other message) |
SAFE storage refused the upload. | Do not retry. Contact SAFE support with the uploadId and upload time (UTC). |
An upload URL is a secret. Anyone who has it can upload that one file, once, until it expires, without signing in. It cannot read, list, delete or overwrite anything. Do not log, store or share upload URLs.
What happens after you upload
A
200on the upload is your confirmation. There is no separate status API: SAFE validates and processes the file in the background.SAFE matches every asset to the assets it already knows (see Asset identity in Step 2) and applies the full-snapshot rule per source.
Lines that cannot be read, or signals SAFE cannot identify, are skipped; the rest of the file is still processed.
The data appears on the Assets and Findings pages after processing. In the Asset Source column, data lake sources show the data lake icon; hover over it to see the source name. Use the Asset Source filter to show one source.
| Situation | What you see in SAFE |
|---|---|
| Qualys and Tenable both report server WEB-01 (each in its own files) | One asset, WEB-01, listing both sources. |
| Both report CVE-2023-44487 on it | The vulnerability is kept once per source on that one asset. |
| The same software appears with a new version | The new version is added; the old one is marked as gone when it stops being sent. |
| A vulnerability is left out of Qualys's next signal for WEB-01 | The vulnerability is closed for Qualys on WEB-01. |
Automate the push
A one-off upload loads SAFE once. To keep it current, run the same flow on a schedule.
- Start with one full push, then push regularly. Send every asset once to load SAFE, then push on a schedule, typically once every 24 hours. Each asset you send must still be its full picture.
- Batch your assets. Put many assets in each file and up to 100 files in each push.
- Do not upload the same asset and source twice at once. Processing order for two files that contain the same asset and source is not guaranteed.
APIs used
The integration uses two SAFE APIs and one upload to SAFE storage. Replace https://{your-tenant}.safescore.ai with your SAFE URL.
| # | API | Purpose | Authentication |
|---|---|---|---|
| 1 | POST /api/v3/auth |
Exchange API credentials for an access token | HTTP Basic: API username and password |
| 2 | POST /api/v3/integrations/file-ingest/uploads |
Request one pre-signed upload URL per file | Authorization: Bearer {accesstoken} |
| 3 | PUT {upload url} (returned by API 2, hosted on Amazon S3) |
Upload one gzipped NDJSON file | None. The URL is pre-signed; send exactly the returned headers. |
Best practices
Start with one full push, then push regularly. Send every asset once to load SAFE, then push changed assets on a schedule, typically once every 24 hours. Each asset you send must still be its full picture.
Keep each source together. Send each source's assets in as few, full files as possible, and within a file keep each source's rows together rather than mixed. One file per tool is the simplest way.
Batch assets. Put many assets in each file and up to 100 files in each push. Avoid one asset per file or one push per asset. At the current rate limit a tenant can request 1,000 upload URLs a minute, for example ten pushes of 100 files.
Do not upload the same asset twice at once. Do not upload two files that contain the same asset and source at the same time: the order in which they are processed is not guaranteed.
Send identity well. Always send
cloudResourceIdfor cloud resources andmacAddresswith generic host names. Keep host names stable between pushes; renaming a device can create a second asset.Keep source names and finding text stable. Changing a source name creates a new source; rewording the name of a non-CVE finding creates a new finding.
Validate before uploading. Check that every line is valid JSON, has the envelope fields,
entity.sourceand an identity field, and uses the JSON types in the field reference.Handle errors. Re-authenticate once on
401; on429wait forRetry-After; on5xxretry with exponential backoff; do not retry other4xxerrors unchanged.Protect secrets. Keep API credentials in a secrets manager and never log access tokens or upload URLs. Log the
uploadIdand file name instead.
Errors and troubleshooting
Upload URL API : POST /api/v3/integrations/file-ingest/uploads
| Status | Cause | Fix |
|---|---|---|
400 |
The body is not {"files": [{"sizeBytes": N}, ...]}, there are 0 or more than 100 files, or a sizeBytes is out of range. The message names the file, for example files[1].sizeBytes must be between 1 and 262144000. |
Correct the body. Split large files. |
401 |
Expired or invalid access token | Authenticate again (Step 1) and retry. |
403 file ingest is not enabled for this tenant |
The API Connector is not enabled for your tenant | Contact your SAFE representative. |
403 (other) |
No access token was sent; the user does not have permission to manage integrations; or the calling IP address is not in your tenant's allowed IP list | Send the token; use API credentials of a user with that permission; or add the IP address to your allowed list. |
429 |
upload URL limit reached for this tenant; retry after N seconds: your tenant has used its upload URLs for the current minute. A 429 without Retry-After is a general API request limit, separate from the upload URL limit. |
Wait the number of seconds in Retry-After (back off exponentially if it is absent), then retry. A request for fewer files may succeed sooner. |
5xx |
Temporary SAFE error; no URLs were returned | Retry with exponential backoff. |
Did this article help?
Let us know by leaving a star rating at the top of this article.
