# Attached documents

Upload, attach, and read files for selected products, customers, suppliers, and purchase orders.

Canonical HTML: https://developer.shelfcycle.com/guides/attached-documents

Upload, attach, and read files for a selected product, customer, supplier, or purchase order.

The upload flow keeps a file private, then attaches it to the exact product, customer, supplier, or purchase order your workflow selected. Product files can also be assigned as the product's current SDS.

## Endpoint map

| Endpoint | Scope | Use it for |
| --- | --- | --- |
| `POST /document-uploads` | `documents:upload` | Prepare one private file upload and return the values needed to send it. |
| `GET /products/{id}/documents` | `products:read` or `search:read` | List document links attached to one visible product. Product responses also include the product SDS pointer when present. |
| `POST /products/{id}/documents` | `products:write` | Attach one uploaded file or HTTPS document link to an active product. |
| `PUT /products/{id}/sds` | `products:write` | Assign one uploaded PDF or image as the product's current SDS. |
| `GET /customers/{id}/documents` | `customers:read` or `search:read` | List document links attached to one visible customer. |
| `POST /customers/{id}/documents` | `customers:write` | Attach one uploaded file or HTTPS document link to an active customer. |
| `GET /purchase-orders/{id}/documents` | `purchase-order-execution:read` | List document links attached to one visible purchase order. |
| `POST /purchase-orders/{id}/documents` | `purchase-order-documents:write` | Attach one uploaded file or HTTPS document link to a visible purchase order. |
| `GET /suppliers/{id}/documents` | `suppliers:read` or `search:read` | List document links attached to one visible supplier. |
| `POST /suppliers/{id}/documents` | `suppliers:write` | Attach one uploaded file or HTTPS document link to an active supplier. |

Document lists stay scoped to the selected parent and report whether the returned set is truncated. Product, customer, and supplier reads can include archived parents, while purchase-order reads follow the selected order and the acting user's current access.

## Read attached documents

Fetch attached documents only after search, a finder, or a detail response has selected the exact parent id.

```bash
curl "$SHELFCYCLE_API_BASE_URL/products/product-id/documents" \
  -H "Authorization: Bearer $SHELFCYCLE_API_KEY"
```

```json
{
  "data": {
    "documents": [
      {
        "type": "document",
        "id": "document-id",
        "fileName": "ACE Spec.pdf",
        "createdAt": "2026-07-02T18:00:00.000Z",
        "urlClass": "shelfcycle",
        "access": {
          "mode": "signed",
          "url": "https://...",
          "expiresAt": "2026-07-02T18:15:00.000Z"
        }
      }
    ],
    "truncated": false,
    "sds": {
      "fileName": "ACE SDS.pdf",
      "access": { "mode": "signed", "url": "https://...", "expiresAt": "2026-07-02T18:15:00.000Z" }
    }
  }
}
```

ShelfCycle-hosted files return short-lived signed read URLs. External HTTPS links return `access.mode: "raw"`. If an old ShelfCycle file points outside the authenticated org prefix, the row can return `access.mode: "unavailable"` without a URL.

## Upload a private file

Start with the local file's name, size, content type, and lowercase hexadecimal SHA-256 checksum. The file can be from 1 byte through 15 MiB.

General attachments accept PDF; JPG/JPEG, PNG, GIF, WebP, and BMP images; and DOCX, XLSX, and PPTX files. A current Product SDS can be a PDF or one of the supported image types.

```bash
FILE_PATH="./ACE-SDS.pdf"
FILE_NAME="$(basename "$FILE_PATH")"
FILE_SIZE="$(wc -c < "$FILE_PATH" | tr -d ' ')"
FILE_SHA256="$(shasum -a 256 "$FILE_PATH" | awk '{print $1}')"

UPLOAD_RESPONSE="$(curl "$SHELFCYCLE_API_BASE_URL/document-uploads" \
  -X POST \
  -H "Authorization: Bearer $SHELFCYCLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"fileName\":\"$FILE_NAME\",\"contentType\":\"application/pdf\",\"sizeBytes\":$FILE_SIZE,\"checksumSha256\":\"$FILE_SHA256\"}")"

UPLOAD_URL="$(printf '%s' "$UPLOAD_RESPONSE" | jq -r '.data.upload.url')"
UPLOAD_CONTENT_LENGTH="$(printf '%s' "$UPLOAD_RESPONSE" | jq -r '.data.upload.headers["Content-Length"]')"
UPLOAD_CONTENT_TYPE="$(printf '%s' "$UPLOAD_RESPONSE" | jq -r '.data.upload.headers["Content-Type"]')"
UPLOAD_CHECKSUM="$(printf '%s' "$UPLOAD_RESPONSE" | jq -r '.data.upload.headers["x-amz-checksum-sha256"]')"
DOCUMENT_FILE_NAME="$(printf '%s' "$UPLOAD_RESPONSE" | jq -r '.data.document.fileName')"
DOCUMENT_URL="$(printf '%s' "$UPLOAD_RESPONSE" | jq -r '.data.document.url')"
```

The response contains two separate values:

- `data.upload` tells the client where and how to send the file during the next 10 minutes.
- `data.document` is the exact `{fileName,url}` value to use when attaching the uploaded file or assigning it as a Product SDS.

```json
{
  "data": {
    "type": "document_upload_grant",
    "id": "upload-id",
    "upload": {
      "method": "PUT",
      "url": "https://upload-url",
      "headers": {
        "Content-Length": "124000",
        "Content-Type": "application/pdf",
        "If-None-Match": "*",
        "x-amz-checksum-sha256": "base64-checksum"
      },
      "expiresAt": "2026-07-22T18:10:00.000Z"
    },
    "document": {
      "fileName": "ACE-SDS.pdf",
      "url": "https://shelfcycle-file-url"
    }
  }
}
```

Send the file to `data.upload.url` with every returned header exactly as provided. Do not send the ShelfCycle API key with this request.

```bash
curl "$UPLOAD_URL" \
  -X PUT \
  --data-binary "@$FILE_PATH" \
  -H "Content-Length: $UPLOAD_CONTENT_LENGTH" \
  -H "Content-Type: $UPLOAD_CONTENT_TYPE" \
  -H "If-None-Match: *" \
  -H "x-amz-checksum-sha256: $UPLOAD_CHECKSUM"
```

After the file upload succeeds, use the returned `data.document` value to attach or assign it. Request a fresh upload when the 10-minute upload window has expired.

## Attach an uploaded file

Submit the returned `data.document` value to the selected product, customer, supplier, or purchase-order document route with a stable idempotency key. The record-specific write scope and the acting user's current permission control the attachment.

```bash
curl "$SHELFCYCLE_API_BASE_URL/customers/customer-id/documents?dryRun=true" \
  -X POST \
  -H "Authorization: Bearer $SHELFCYCLE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: intake:customer-id:w9-upload" \
  -d "$(jq -n --arg fileName "$DOCUMENT_FILE_NAME" --arg url "$DOCUMENT_URL" '{fileName:$fileName,url:$url}')"
```

```json
{
  "data": {
    "type": "write_readiness",
    "operation": "customer_documents.create",
    "status": "ready",
    "wouldWrite": false,
    "checks": [
      { "code": "scope_authorized", "status": "passed" },
      { "code": "target_available", "status": "passed" },
      { "code": "idempotency_available", "status": "passed" },
      { "code": "duplicate_check_clear", "status": "passed" }
    ],
    "duplicateCandidates": [],
    "verification": {
      "available": true,
      "path": "/api/v1/customers/customer-id/documents",
      "fallback": "detail_get"
    },
    "requestId": "request-id"
  }
}
```

Execute the same request without `dryRun=true` when your integration policy allows it. A successful uploaded-file attachment returns a durable integrity receipt:

```json
{
  "data": {
    "type": "document",
    "id": "document-id",
    "fileName": "ACE-SDS.pdf",
    "createdAt": "2026-08-11T18:00:00.000Z",
    "urlClass": "shelfcycle",
    "access": {
      "mode": "signed",
      "url": "https://...",
      "expiresAt": "2026-08-11T18:15:00.000Z"
    },
    "idempotencyStatus": "created",
    "integrity": {
      "status": "verified",
      "checksumSha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
      "sizeBytes": 124000,
      "contentType": "application/pdf",
      "verifiedAt": "2026-08-11T18:00:00.000Z"
    }
  }
}
```

`integrity.status: "verified"` confirms that ShelfCycle matched the stored file's checksum, size, and content type when it attached the file. Reusing the same route, idempotency key, and body returns the existing document row with `idempotencyStatus: "replayed"` and the same stored integrity receipt, even when the original response was lost.

An external HTTPS link returns `integrity.status: "not_verified"`. An uploaded file attached before integrity receipts were recorded can return `integrity.status: "unavailable"` on replay. A dry run checks that the upload is ready but creates no receipt; execution verifies the upload again and records the receipt.

The upload scope prepares the file but does not attach it. The matching record write scope controls the association. To receive a readable URL in the create response, the key must also be able to read that target record; otherwise `access.mode` is `unavailable` and no URL is returned.

## Assign the current Product SDS

Use the same uploaded `data.document` value with the latest product `updatedAt`. A dry run is available before assignment.

```bash
curl "$SHELFCYCLE_API_BASE_URL/products/product-id/sds?dryRun=true" \
  -X PUT \
  -H "Authorization: Bearer $SHELFCYCLE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "If-Match: 2026-07-22T17:45:00.000Z" \
  -d "$(jq -n --arg fileName "$DOCUMENT_FILE_NAME" --arg url "$DOCUMENT_URL" '{fileName:$fileName,url:$url}')"
```

Remove `dryRun=true` to assign the SDS after the readiness response is `ready`. The response reports the product id, file name, new `updatedAt`, and whether the SDS was updated, replayed, or repaired. Verify the result with `GET /products/{id}/documents`; `data.sds` is the current SDS and `data.documents` keeps the product's attached-file history.

## Attach an existing HTTPS link

The product, customer, supplier, and purchase-order document routes also accept an existing HTTPS file link. Document create accepts exactly `fileName` and `url`; the response identifies these links with `integrity.status: "not_verified"` so callers can distinguish them from verified uploads.

## URL rules

Document URLs must use `https` and cannot contain embedded credentials. External HTTPS links are accepted. ShelfCycle bucket links are accepted only when the bucket key belongs to the authenticated org; signed URL query strings are normalized before duplicate checks.

Exact duplicate URLs on the same parent are blocked with `duplicate_document`. Requests with fields other than `fileName` and `url` are blocked with `unsupported_document_field`.

## Sensitive files

Attached files may contain tax, quality, commercial, or regulatory context. Use the narrowest parent route, keep signed URLs out of logs, and avoid echoing request bodies that contain sensitive source links.

> **Guardrail**: Choose the exact parent record before attaching a file, keep upload and signed read URLs out of logs, and verify the final attachment or Product SDS through that record's document list.
