> For the complete documentation index, see [llms.txt](https://docs.interlynk.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.interlynk.io/api/managing-sboms/upload-sbom.md).

# Upload an SBOM

Uploading creates a new version inside a product's environment. Each upload is a new version, so you do not overwrite anything.

Uploads are the one API call that does not use a JSON body. A file upload over GraphQL uses `multipart/form-data`, following the [GraphQL multipart request spec](https://github.com/jaydenseric/graphql-multipart-request-spec). The sections below show exactly what that means for curl.

## The request

A multipart upload sends three parts:

| Part         | Contents                                                                             |
| ------------ | ------------------------------------------------------------------------------------ |
| `operations` | The GraphQL mutation and its variables, as JSON. The file variable is set to `null`. |
| `map`        | Tells the server which uploaded file fills which variable.                           |
| `0`          | The SBOM file itself. The part name (`0`) matches the key in `map`.                  |

## Upload by product name

The simplest form. Name the product and environment, and the API resolves them.

```bash
curl https://api.interlynk.io/lynkapi \
  -H "Authorization: Bearer $INTERLYNK_SECURITY_TOKEN" \
  -F operations='{"query":"mutation uploadSbom($doc: Upload!, $projectGroupName: String, $projectName: String) { sbomUpload(input: { doc: $doc, projectGroupName: $projectGroupName, projectName: $projectName }) { errors } }","variables":{"doc":null,"projectGroupName":"payments-service","projectName":"default"}}' \
  -F map='{"0":["variables.doc"]}' \
  -F 0=@my-sbom.cdx.json
```

A successful upload returns an empty `errors` list:

```json
{
  "data": {
    "sbomUpload": {
      "errors": []
    }
  }
}
```

{% hint style="info" %}
Do not set `Content-Type` yourself. When you use `-F`, curl sets `multipart/form-data` with the correct boundary automatically.
{% endhint %}

## Upload by ID

If you already have a product ID and environment ID, pass them instead of names. IDs are exact and skip the name lookup.

```bash
curl https://api.interlynk.io/lynkapi \
  -H "Authorization: Bearer $INTERLYNK_SECURITY_TOKEN" \
  -F operations='{"query":"mutation uploadSbom($doc: Upload!, $projectGroupId: ID, $projectId: ID) { sbomUpload(input: { doc: $doc, projectGroupId: $projectGroupId, projectId: $projectId }) { errors } }","variables":{"doc":null,"projectGroupId":"26ae44b7-2f68-4cf4-a405-d5ee0177bb11","projectId":"1fade833-0603-4139-8ca0-26592264a4c9"}}' \
  -F map='{"0":["variables.doc"]}' \
  -F 0=@my-sbom.cdx.json
```

## Choosing where it goes

`sbomUpload` accepts these target inputs. Pass the product and the environment.

| Input              | Type   | Identifies                                                    |
| ------------------ | ------ | ------------------------------------------------------------- |
| `projectGroupName` | String | Product, by name                                              |
| `projectGroupId`   | ID     | Product, by ID                                                |
| `projectName`      | String | Environment, by name (`default`, `development`, `production`) |
| `projectId`        | ID     | Environment, by ID                                            |

If you omit the environment, the upload goes to `default`.

## Supported file formats

* CycloneDX, JSON and XML
* SPDX, JSON and tag-value

## After the upload

A new version is created and the platform starts processing it: vulnerability scanning, automation, and policy checks. The SBOM is not fully ready to download until processing finishes.

* [Check Processing Status](/api/managing-sboms/processing-status.md) shows how to tell when it is done.
* [List Products and Versions](/api/inventory/list-resources.md) shows the new version and its ID.

## Errors

If the upload fails, the reason is in the `errors` list:

```json
{
  "data": {
    "sbomUpload": {
      "errors": ["Project group not found"]
    }
  }
}
```

Common causes:

| Message                   | Cause                                                              |
| ------------------------- | ------------------------------------------------------------------ |
| `Project group not found` | The product name or ID does not exist, or the token cannot see it. |
| HTTP `401`                | The token is missing, expired, or wrong.                           |

See [Errors](/api/reference/errors.md) for more.

## Recording build provenance

When you upload from a CI/CD pipeline, you can attach provenance: the event, commit, build, and repository behind this SBOM. The platform records it with the new version, so you can trace any version back to the build that produced it.

Provenance travels as `X-` HTTP headers on the upload request. The multipart body and GraphQL mutation are unchanged. Every header is optional and independent, so send the ones you have and omit the rest.

This is what the [`pylynk`](https://github.com/interlynk-io/pylynk) CLI does for you automatically. The examples below show how to send the same headers from curl.

### Provenance headers

| Header               | Meaning                                                                              |
| -------------------- | ------------------------------------------------------------------------------------ |
| `X-CI-Provider`      | CI system: `github_actions`, `azure_devops`, `bitbucket_pipelines`, or `generic_ci`. |
| `X-Event-Type`       | What triggered the build: `push`, `pull_request`, or `release`.                      |
| `X-Release-Tag`      | Tag name, for a release or tag build.                                                |
| `X-PR-Number`        | Pull request number.                                                                 |
| `X-PR-URL`           | Pull request URL.                                                                    |
| `X-PR-Source-Branch` | Branch being merged from.                                                            |
| `X-PR-Target-Branch` | Branch being merged into.                                                            |
| `X-PR-Author`        | User who triggered the build.                                                        |
| `X-Build-URL`        | Link to the CI run.                                                                  |
| `X-Commit-SHA`       | Commit hash.                                                                         |
| `X-Repository-URL`   | Repository URL.                                                                      |

### From GitHub Actions

GitHub Actions exposes most of these as built-in variables.

```bash
curl https://api.interlynk.io/lynkapi \
  -H "Authorization: Bearer $INTERLYNK_SECURITY_TOKEN" \
  -H "X-CI-Provider: github_actions" \
  -H "X-Event-Type: $GITHUB_EVENT_NAME" \
  -H "X-Commit-SHA: $GITHUB_SHA" \
  -H "X-PR-Author: $GITHUB_ACTOR" \
  -H "X-Build-URL: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
  -H "X-Repository-URL: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \
  -F operations='{"query":"mutation uploadSbom($doc: Upload!, $projectGroupName: String, $projectName: String) { sbomUpload(input: { doc: $doc, projectGroupName: $projectGroupName, projectName: $projectName }) { errors } }","variables":{"doc":null,"projectGroupName":"payments-service","projectName":"default"}}' \
  -F map='{"0":["variables.doc"]}' \
  -F 0=@my-sbom.cdx.json
```

{% hint style="info" %}
A tag push has `GITHUB_REF` like `refs/tags/v1.2.3` and is a release. Add `-H "X-Release-Tag: ${GITHUB_REF#refs/tags/}"`. Pull request fields (number, URL, branches) are not in plain variables; they live in the event payload at `$GITHUB_EVENT_PATH`, which you can read with `jq`.
{% endhint %}

### From Azure DevOps

```bash
curl https://api.interlynk.io/lynkapi \
  -H "Authorization: Bearer $INTERLYNK_SECURITY_TOKEN" \
  -H "X-CI-Provider: azure_devops" \
  -H "X-Commit-SHA: $BUILD_SOURCEVERSION" \
  -H "X-PR-Number: $SYSTEM_PULLREQUEST_PULLREQUESTID" \
  -H "X-PR-Source-Branch: $SYSTEM_PULLREQUEST_SOURCEBRANCH" \
  -H "X-PR-Target-Branch: $SYSTEM_PULLREQUEST_TARGETBRANCH" \
  -H "X-PR-Author: $BUILD_REQUESTEDFOR" \
  -H "X-Build-URL: ${SYSTEM_TEAMFOUNDATIONCOLLECTIONURI}${SYSTEM_TEAMPROJECT}/_build/results?buildId=$BUILD_BUILDID" \
  -H "X-Repository-URL: $BUILD_REPOSITORY_URI" \
  -F operations='{"query":"mutation uploadSbom($doc: Upload!, $projectGroupName: String, $projectName: String) { sbomUpload(input: { doc: $doc, projectGroupName: $projectGroupName, projectName: $projectName }) { errors } }","variables":{"doc":null,"projectGroupName":"payments-service","projectName":"default"}}' \
  -F map='{"0":["variables.doc"]}' \
  -F 0=@my-sbom.cdx.json
```

The `SYSTEM_PULLREQUEST_*` variables are only set on pull request builds. On a plain commit, drop the `X-PR-*` headers.

### From Bitbucket Pipelines

```bash
curl https://api.interlynk.io/lynkapi \
  -H "Authorization: Bearer $INTERLYNK_SECURITY_TOKEN" \
  -H "X-CI-Provider: bitbucket_pipelines" \
  -H "X-Commit-SHA: $BITBUCKET_COMMIT" \
  -H "X-PR-Number: $BITBUCKET_PR_ID" \
  -H "X-PR-Source-Branch: $BITBUCKET_BRANCH" \
  -H "X-PR-Target-Branch: $BITBUCKET_PR_DESTINATION_BRANCH" \
  -H "X-Release-Tag: $BITBUCKET_TAG" \
  -H "X-Build-URL: https://bitbucket.org/$BITBUCKET_WORKSPACE/$BITBUCKET_REPO_SLUG/pipelines/results/$BITBUCKET_BUILD_NUMBER" \
  -H "X-Repository-URL: https://bitbucket.org/$BITBUCKET_WORKSPACE/$BITBUCKET_REPO_SLUG" \
  -F operations='{"query":"mutation uploadSbom($doc: Upload!, $projectGroupName: String, $projectName: String) { sbomUpload(input: { doc: $doc, projectGroupName: $projectGroupName, projectName: $projectName }) { errors } }","variables":{"doc":null,"projectGroupName":"payments-service","projectName":"default"}}' \
  -F map='{"0":["variables.doc"]}' \
  -F 0=@my-sbom.cdx.json
```

`BITBUCKET_TAG` is set only on tag builds and `BITBUCKET_PR_*` only on pull request builds. Drop whichever headers are empty.

### From any other CI

On any other system, set the headers from whatever variables your CI exposes. Use `generic_ci` as the provider and fill in what you have.

```bash
curl https://api.interlynk.io/lynkapi \
  -H "Authorization: Bearer $INTERLYNK_SECURITY_TOKEN" \
  -H "X-CI-Provider: generic_ci" \
  -H "X-Commit-SHA: $GIT_COMMIT" \
  -H "X-Build-URL: $BUILD_URL" \
  -H "X-Repository-URL: $REPO_URL" \
  -F operations='{"query":"mutation uploadSbom($doc: Upload!, $projectGroupName: String, $projectName: String) { sbomUpload(input: { doc: $doc, projectGroupName: $projectGroupName, projectName: $projectName }) { errors } }","variables":{"doc":null,"projectGroupName":"payments-service","projectName":"default"}}' \
  -F map='{"0":["variables.doc"]}' \
  -F 0=@my-sbom.cdx.json
```

{% hint style="info" %}
[`pylynk`](https://github.com/interlynk-io/pylynk) detects GitHub Actions, Azure DevOps, and Bitbucket Pipelines and sets all of these headers automatically. If you already run it in your pipeline, you get provenance without any of the above.
{% endhint %}

## Retrying failed uploads

In a CI/CD pipeline, a single upload attempt is not reliable. Network blips, rate limits, and brief server errors all cause failures that succeed on a retry. Retry transient failures with exponential backoff, and fail fast on permanent ones like a bad token.

### Which failures to retry

Retrying a bad token wastes time and never succeeds. Retrying a server error often works on the next attempt.

| Failure                           | Retry? | Why                                                                     |
| --------------------------------- | ------ | ----------------------------------------------------------------------- |
| Network error or timeout          | Yes    | Transient. The next attempt usually connects.                           |
| HTTP 429 (rate limited)           | Yes    | The server is asking you to slow down. Back off and retry.              |
| HTTP 500, 502, 503, 504           | Yes    | Transient server error.                                                 |
| HTTP 401 (unauthorized)           | No     | The token is wrong or expired. Fix it.                                  |
| HTTP 400, 403, 404                | No     | The request is wrong. Retrying sends the same bad request.              |
| HTTP 200 with `sbomUpload.errors` | No     | The upload was rejected, for example an unknown product. Fix the input. |

Between retries, wait longer each time. Doubling the delay (1s, then 2s, then 4s) gives the API room to recover and avoids a tight retry loop. This is exponential backoff.

### Option 1: curl's built-in retry

curl has exponential backoff built in. The `--retry` flag retries transient failures and doubles the wait between attempts on its own.

```bash
curl --retry 5 --retry-connrefused --fail-with-body \
  https://api.interlynk.io/lynkapi \
  -H "Authorization: Bearer $INTERLYNK_SECURITY_TOKEN" \
  -F operations='{"query":"mutation uploadSbom($doc: Upload!, $projectGroupName: String, $projectName: String) { sbomUpload(input: { doc: $doc, projectGroupName: $projectGroupName, projectName: $projectName }) { errors } }","variables":{"doc":null,"projectGroupName":"payments-service","projectName":"default"}}' \
  -F map='{"0":["variables.doc"]}' \
  -F 0=@my-sbom.cdx.json
```

| Flag                  | Effect                                                                   |
| --------------------- | ------------------------------------------------------------------------ |
| `--retry 5`           | Retry up to 5 times. curl waits 1s, then 2s, 4s, 8s, doubling each time. |
| `--retry-connrefused` | Also retry when the connection is refused.                               |
| `--fail-with-body`    | Exit non-zero on an HTTP error, but still print the response body.       |

{% hint style="info" %}
Do not add `--retry-delay`. Setting a fixed delay turns off curl's exponential backoff. Leave it off and the delay doubles automatically.
{% endhint %}

curl's `--retry` retries the transient cases for you: timeouts and HTTP 408, 429, 500, 502, 503, and 504. It does not retry 401 or other 4xx errors, which is what you want.

What `--retry` cannot see is a GraphQL-level rejection. If the API returns HTTP `200` with a non-empty `sbomUpload.errors` list, curl treats the request as a success. Always check that field yourself:

```bash
RESPONSE=$(curl -s --retry 5 --retry-connrefused \
  https://api.interlynk.io/lynkapi \
  -H "Authorization: Bearer $INTERLYNK_SECURITY_TOKEN" \
  -F operations='{"query":"mutation uploadSbom($doc: Upload!, $projectGroupName: String, $projectName: String) { sbomUpload(input: { doc: $doc, projectGroupName: $projectGroupName, projectName: $projectName }) { errors } }","variables":{"doc":null,"projectGroupName":"payments-service","projectName":"default"}}' \
  -F map='{"0":["variables.doc"]}' \
  -F 0=@my-sbom.cdx.json)

if [ "$(echo "$RESPONSE" | jq -r '.data.sbomUpload.errors | length')" != "0" ]; then
  echo "Upload rejected: $(echo "$RESPONSE" | jq -c '.data.sbomUpload.errors')"
  exit 1
fi
echo "Upload succeeded."
```

This option is enough for most pipelines.

### Option 2: a retry script

When you want full control, for example to log each attempt or to treat the GraphQL `errors` field as a hard stop, use an explicit loop.

```bash
#!/usr/bin/env bash
set -euo pipefail

ENDPOINT="https://api.interlynk.io/lynkapi"
SBOM_FILE="my-sbom.cdx.json"
PRODUCT="payments-service"
ENVIRONMENT="default"
MAX_ATTEMPTS=4   # 1 initial attempt + 3 retries

OPERATIONS='{"query":"mutation uploadSbom($doc: Upload!, $projectGroupName: String, $projectName: String) { sbomUpload(input: { doc: $doc, projectGroupName: $projectGroupName, projectName: $projectName }) { errors } }","variables":{"doc":null,"projectGroupName":"'"$PRODUCT"'","projectName":"'"$ENVIRONMENT"'"}}'

attempt=1
while true; do
  body=$(mktemp)
  # %{http_code} is the HTTP status; "000" means curl could not reach the server.
  status=$(curl -s -o "$body" -w '%{http_code}' "$ENDPOINT" \
    -H "Authorization: Bearer $INTERLYNK_SECURITY_TOKEN" \
    -F operations="$OPERATIONS" \
    -F map='{"0":["variables.doc"]}' \
    -F 0=@"$SBOM_FILE" || echo "000")

  if [ "$status" = "200" ]; then
    errors=$(jq -c '.data.sbomUpload.errors' < "$body")
    rm -f "$body"
    if [ "$errors" = "[]" ]; then
      echo "Upload succeeded."
      exit 0
    fi
    # Rejected by the API. This is permanent, do not retry.
    echo "Upload rejected: $errors"
    exit 1
  fi
  rm -f "$body"

  # Permanent HTTP failures: fix the request, do not retry.
  if [ "$status" = "401" ] || \
     { [ "$status" -ge 400 ] 2>/dev/null && [ "$status" -lt 500 ] && [ "$status" != "429" ]; }; then
    echo "Upload failed with HTTP $status. Not retrying."
    exit 1
  fi

  # Transient failure: network error ("000"), 429, or 5xx.
  if [ "$attempt" -ge "$MAX_ATTEMPTS" ]; then
    echo "Upload failed after $attempt attempts. Last status: $status."
    exit 1
  fi

  delay=$(( 2 ** (attempt - 1) ))   # 1s, 2s, 4s
  echo "Attempt $attempt failed (status $status). Retrying in ${delay}s..."
  sleep "$delay"
  attempt=$(( attempt + 1 ))
done
```

This is the same strategy the [`pylynk`](https://github.com/interlynk-io/pylynk) CLI uses: three retries with a 1s, 2s, 4s backoff, no retry on authentication or client errors.

### A note on duplicate versions

Every successful `sbomUpload` creates a new version. There is no upload ID you can reuse to make a retry idempotent.

This matters in one specific case: the server accepts the upload, but the response is lost on the way back to you (a dropped connection at exactly the wrong moment). Your script sees a failure and retries, and the retry creates a second version of the same SBOM.

This is rare and usually harmless, since the versions are identical. If your pipeline cannot tolerate it, after a retry that followed an unclear failure, [list the versions](/api/inventory/list-resources.md) for that product and environment and remove any duplicate.

### Add jitter for fleets of pipelines

If many pipelines upload at once and all hit a rate limit together, they will all back off by the same amount and retry at the same moment, hitting the limit again. Add a small random delay (jitter) so the retries spread out:

```bash
delay=$(( 2 ** (attempt - 1) ))
jitter=$(( RANDOM % 1000 ))           # 0 to 999 milliseconds
sleep "$(printf '%d.%03d' "$delay" "$jitter")"
```
