# Write readiness

Use optional dryRun checks before supported writes.

Canonical HTML: https://developer.shelfcycle.com/guides/write-readiness

Use optional non-mutating dryRun checks before supported POST, PATCH, PUT, and DELETE writes.

The API supports `dryRun=true` on Chart of Accounts, Shipping Options, tags and tag assignments, representatives, payment terms, notes, contacts, customer, supplier, warehouse, reorder-point, parent-scoped child location/address, archive and restore, attached document, product, product-family, Product SDS, Cost Book, Current Source, and Price Book write endpoints. A dry run checks the exact intended request without creating or updating records.

`dryRun=true` is optional. Authorized callers may execute directly when their integration policy allows it. ShelfCycle does not require a human approval step before supported public API writes.

## Supported endpoints

| Endpoint | Scope | Execution header |
| --- | --- | --- |
| `POST /reference/ledger-accounts` | `company-reference:write` | `Idempotency-Key` when `externalId` is absent |
| `PATCH /reference/ledger-accounts/{id}` | `company-reference:write` | `If-Match` |
| `POST /shipping-options` | `shipping-options:write` | `Idempotency-Key` for a new option |
| `PATCH /shipping-options/{id}`, `POST /shipping-options/{id}/archive`, or `/unarchive` | `shipping-options:write` | `If-Match` |
| `POST /warehouses` | `warehouses:write` | `Idempotency-Key` when `externalId` is absent |
| `PATCH /warehouses/{id}`, `POST /warehouses/{id}/archive`, or `/unarchive` | `warehouses:write` | `If-Match` |
| `POST /reorder-points` | `reorder:write` | None |
| `POST /tags`, `PATCH /tags/{id}`, and `POST` or `DELETE /tag-assignments` | `tags:write` | None |
| `POST /representatives`, representative `PATCH`, `archive`, or `unarchive` | `company-reference:write` | `Idempotency-Key` for create; `If-Match` for update, archive, or restore |
| `POST /payment-terms`, `PATCH /payment-terms/{id}` | `company-reference:write` | `Idempotency-Key` for create; `If-Match` for update |
| `POST /notes` | `notes:write` | `Idempotency-Key` |
| `PATCH /notes/{id}` | `notes:write` | `If-Match` |
| `POST /contacts` | `contacts:create` | `Idempotency-Key` |
| `PATCH /contacts/{id}` | `contacts:update` | `If-Match` |
| `POST /contacts/{id}/archive` or `/unarchive` | `contacts:update` | `If-Match` |
| `POST /customers`, `POST /suppliers` | `customers:write` or `suppliers:write` | `Idempotency-Key` |
| `PATCH /customers/{id}`, `PATCH /suppliers/{id}` | `customers:write` or `suppliers:write` | `If-Match` |
| `POST /customers/{id}/archive` or `/unarchive`, `POST /suppliers/{id}/archive` or `/unarchive` | `customers:write` or `suppliers:write` | `If-Match` |
| `POST /customers/{id}/locations`, `POST /customers/{id}/locations/{locationId}/addresses` | `customers:write` | `Idempotency-Key` |
| `PATCH /customers/{id}/locations/{locationId}`, `PATCH /customers/{id}/locations/{locationId}/addresses/{addressId}` | `customers:write` | `If-Match` |
| Customer-address `archive` or `unarchive` action | `customers:write` | `If-Match` |
| `POST /customers/{id}/documents` | `customers:write` | `Idempotency-Key` |
| `POST /suppliers/{id}/ship-from-locations` | `suppliers:write` | `Idempotency-Key` |
| `PATCH /suppliers/{id}/ship-from-locations/{locationId}` | `suppliers:write` | `If-Match` |
| Supplier ship-from `archive` or `unarchive` action | `suppliers:write` | `If-Match` |
| `POST /suppliers/{id}/documents` | `suppliers:write` | `Idempotency-Key` |
| `POST /products` | `products:write` | `Idempotency-Key` |
| `PATCH /products/{id}` | `products:write` | `If-Match` |
| `POST /products/{id}/documents` | `products:write` | `Idempotency-Key` |
| `PUT /products/{id}/sds` | `products:write` | `If-Match` |
| `POST /product-families` | `products:write` | `Idempotency-Key` |
| `PATCH /product-families/{id}` | `products:write` | `If-Match` |
| `POST /cost-book-entries` | `cost-book-entries:write` | `Idempotency-Key` |
| `PATCH /cost-book-entries/{id}` | `cost-book-entries:write` | `Idempotency-Key` |
| `DELETE /cost-book-entries/{id}` | `cost-book-entries:write` | `Idempotency-Key` |
| `POST /cost-book-entries/batch` | `cost-book-entries:write` | `Idempotency-Key` |
| `PUT /products/{id}/current-cost-source` | `current-cost-sources:write` | `expectedCurrentSupplierId` in the body |
| `POST /price-book-entries` | `price-book-entries:write` | `Idempotency-Key` |

Dry runs use the same write scope, live user permission, and rate-limit family as execution. They do not introduce a separate permission model.

## Request

Send the same method, endpoint, headers, and body you plan to execute, with `dryRun=true` in the query string.

```bash
curl "$SHELFCYCLE_API_BASE_URL/contacts?dryRun=true" \
  -X POST \
  -H "Authorization: Bearer $SHELFCYCLE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: gmail-thread-123:customer-id:create-contact:jane@example.com" \
  -d '{
    "parent": { "type": "customer", "id": "customer-id" },
    "name": "Jane Buyer",
    "title": "Purchasing",
    "email": "jane@example.com",
    "phone": "555-0100"
  }'
```

`dryRun` must be exactly `true`. Invalid values such as `dryRun=false`, `dryRun=1`, or repeated `dryRun` parameters fail validation and must not be retried as execution.

Price Book uses a review-and-apply sequence. Its dry run returns `expectedAffectedEntryIds` and `expectedPlanToken`; include both in the later execution while keeping the same stable `Idempotency-Key`. See the [Price Book guide](/guides/price-book) for the complete request shape.

Current Source uses a read-preview-apply sequence. Read `GET /products/{id}/current-cost-source`, preview the desired `supplierId` and `shipFromLocationId` with the observed `expectedCurrentSupplierId` and `expectedCurrentShipFromLocationId`, then send the same PUT without `dryRun=true`. The preview returns the desired and previous source, `consequence`, eligibility, and a verification path. See the [Cost Book guide](/guides/cost-book) for set, replace, and clear examples.

Cost Book updates, corrections, and batches use the same preview-and-apply pattern. Preview the exact POST or PATCH with `dryRun=true`, review every readiness check and predicted entry, then execute the identical request with the same stable `Idempotency-Key`. Batch preview validates the whole envelope and every item without saving results.

Archive and restore actions use a current-record preview-and-apply pattern. Preview the selected action without `If-Match` to receive `impactAnalysis.currentUpdatedAt`, review the target state and any customer or supplier safeguards, then execute with that timestamp in `If-Match`. See [Archive and restore](/guides/archive-restore) for the complete flow.

Shipping Options use the same preview-and-apply pattern. Create with a stable `Idempotency-Key`; update, archive, or restore with the selected option's latest `updatedAt` value in `If-Match`. See [Shipping Options](/guides/shipping-options) for complete examples.

Chart of Accounts uses the same pattern. Create with a stable `Idempotency-Key` when no source ID is available; update with the selected account's latest `updatedAt` value in `If-Match`. See [Chart of Accounts](/guides/chart-of-accounts) for complete examples.

## Create a separate source record

Customer, supplier, contact, combined customer-location, customer-address, and supplier ship-from creates accept optional top-level `duplicateResolution: "create_distinct"`. Use it only when a reviewable similarity represents a separate legitimate source record. Without it, similarity candidates keep the write blocked for review.

The option does not change exact source-ID, archived-record, hidden-candidate, address-role, reference, or customer-location-name conflicts. Dry run is optional; execution checks the current records again.

## Response

Most dry runs return HTTP 200 with `data.type: "write_readiness"` when the request can be evaluated. Current Source returns its focused `status`, `consequence`, eligibility, and verification fields. Use response data, not HTTP status alone, to decide what happened.

```json
{
  "data": {
    "type": "write_readiness",
    "operation": "contacts.create",
    "status": "ready",
    "wouldWrite": false,
    "checks": [
      { "code": "scope_authorized", "status": "passed", "message": "The key can create contacts." },
      { "code": "parent_available", "status": "passed", "message": "The parent is visible and active." },
      { "code": "duplicate_check_clear", "status": "passed", "message": "No duplicate contact candidate was found." },
      { "code": "idempotency_available", "status": "passed", "message": "The idempotency key is available." }
    ],
    "duplicateCandidates": [],
    "idempotency": { "required": true, "keyProvided": true, "wouldReplay": false },
    "submit": { "method": "POST", "path": "/api/v1/contacts", "query": {} },
    "verification": {
      "available": true,
      "method": "GET",
      "path": null,
      "pathTemplate": "/api/v1/contacts/{id}",
      "requiresScopes": ["contacts:read"],
      "fallback": "detail_get"
    },
    "requestId": "request-id"
  }
}
```

`wouldWrite` is always `false` for dry runs. A dry run must not create business rows, update `updatedAt`, create idempotency identities, write embeddings, acquire mutation locks, or trigger downstream side effects.

## Ready and blocked

`status: "ready"` means the request passed the available checks at that moment. Execution confirms the request again against current ShelfCycle data.

`status: "blocked"` means the API found a condition your workflow must handle before execution. Common blocked checks include:

```text
duplicate_candidate_found
duplicate_candidate_overflow
duplicate_reference_identity
idempotency_key_required
idempotency_key_reused
stale_record
target_not_found_or_not_visible
unsupported_profile_shape
unsupported_linked_record
not_api_created
primary_change_unsupported
address_role_required
product_family_reference_active
product_packaging_reference_active
supplier_reference_active
reference_visibility_blocked
payment_terms_reference_exists
sales_person_reference_active
customer_service_representative_reference_active
supplier_representative_reference_active
ledger_account_reference_valid
cost_ship_from_location_not_visible
cost_book_base_ambiguous
invalid_variable_qty
order_sensitive_update_blocked
validation_failed
archived_record
archive_blocked
duplicate_document
```

Duplicate candidates are advisory and bounded. If the key cannot read the matching detail route, the candidate can be redacted to `readable: false` with only `type` and `matchReasons`.

## Execute and verify

To execute, remove `dryRun=true` and send the same request when your integration policy allows it.

POST execution requires `Idempotency-Key`. Most PATCH endpoints and Product SDS execution require `If-Match`; Cost Book PATCH instead requires `Idempotency-Key` and `expectedCurrentEntryId`. Current Source PUT requires the supplier and ship-from location observed before the change as `expectedCurrentSupplierId` and `expectedCurrentShipFromLocationId`. Execution can still return `409` if another write changes state after the dry run.

After execution, use `verification.path` when the response provides one and the key has the required scope. If verification is not available, use the execute response as readback. Price Book execution returns the complete saved entry and ready `links.self` and `links.history` paths.

> **Guardrail**: A readiness response never grants extra access. Use the exact record-specific scope for an attachment or SDS assignment, and verify the result through the selected record's read route.
