> ## Documentation Index
> Fetch the complete documentation index at: https://code.dcycle.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Create Purchases (v2)

> Create up to 5,000 purchases in one request; suppliers are classified and emissions calculated asynchronously

[← Purchases API](/api-reference/purchases/overview)

Create one or many purchases of goods and services (Scope 3 category 1, or category 2 for `capex`). Say what was
bought, from whom, when and for how much; Dcycle picks the emission factor and calculates the emissions in the
background. The purchases are the ones [`/v1/purchases`](/api-reference/purchases/list) reads and edits.

The request checks every record and writes them all at once, then answers `202 Accepted` with an
[ingest job](/api-reference/ingest-jobs/get) that classifies and calculates them. Follow it with
[`GET /v2/ingest-jobs/{id}`](/api-reference/ingest-jobs/get) or the
[`ingest_job.finished`](/api-reference/webhooks/overview) webhook.

<Warning>
  **Beta.** Part of the bulk ingest API, currently in beta. The contract may still change before general availability.
</Warning>

<RequestExample>
  ```bash cURL theme={"theme":{"light":"github-light","dark":"github-dark"}}
  curl -X POST "https://api.dcycle.io/v2/purchases" \
    -H "x-api-key: ${DCYCLE_API_KEY}" \
    -H "x-organization-id: ${DCYCLE_ORG_ID}" \
    -H "Idempotency-Key: 4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a" \
    -H "Content-Type: application/json" \
    -d '{
      "records": [
        {
          "purchase_date": "2026-03-15",
          "expense_type": "opex",
          "quantity": 1250.5,
          "unit_id": "d3e37f2b-0fc3-4532-82f8-3890ab56ad37",
          "supplier_name": "Papelera del Norte SL",
          "supplier_country": "ES",
          "description": "Office paper, 500 reams",
          "client_row_id": "PO-2026-0042"
        },
        {
          "purchase_date": "2026-03-20",
          "expense_type": "capex",
          "quantity": 18900,
          "unit_id": "d3e37f2b-0fc3-4532-82f8-3890ab56ad37",
          "supplier_id": "5f4e3d2c-1b0a-4f9e-8d7c-6b5a4f3e2d1c"
        }
      ]
    }'
  ```

  ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}}
  import os
  import uuid

  import requests

  response = requests.post(
      "https://api.dcycle.io/v2/purchases",
      headers={
          "x-api-key": os.environ["DCYCLE_API_KEY"],
          "x-organization-id": os.environ["DCYCLE_ORG_ID"],
          "Idempotency-Key": str(uuid.uuid4()),  # keep it to retry this same batch
      },
      json={
          "records": [
              {
                  "purchase_date": "2026-03-15",
                  "expense_type": "opex",
                  "quantity": 1250.5,
                  "unit_id": "d3e37f2b-0fc3-4532-82f8-3890ab56ad37",
                  "supplier_name": "Papelera del Norte SL",
                  "supplier_country": "ES",
                  "description": "Office paper, 500 reams",
                  "client_row_id": "PO-2026-0042"
              }
          ]
      },
      timeout=60,
  )
  response.raise_for_status()
  job = response.json()
  print(job["id"], job["status"])
  ```
</RequestExample>

<ResponseExample>
  ```json 202 theme={"theme":{"light":"github-light","dark":"github-dark"}}
  {
    "id": "1e2d3c4b-5a69-4f87-9e6d-5c4b3a291807",
    "status": "processing",
    "entity_type": "purchases",
    "operation": "create",
    "source": "api_bulk",
    "counts": { "submitted": 2, "succeeded": 0, "failed": 0 },
    "chunks_total": 1,
    "chunks_done": 0,
    "created_at": "2026-10-15T09:12:44.031Z",
    "finished_at": null,
    "links": {
      "self": "/v2/ingest-jobs/1e2d3c4b-5a69-4f87-9e6d-5c4b3a291807",
      "items": "/v2/ingest-jobs/1e2d3c4b-5a69-4f87-9e6d-5c4b3a291807/items"
    }
  }
  ```

  ```json 422 theme={"theme":{"light":"github-light","dark":"github-dark"}}
  {
    "type": "https://code.dcycle.io/api-reference/errors#BULK_RECORDS_REJECTED",
    "title": "Unprocessable Entity",
    "status": 422,
    "code": "BULK_RECORDS_REJECTED",
    "detail": "1 of the submitted records are invalid; nothing was written.",
    "errors": [
      {
        "source_row_index": 0,
        "client_row_id": "PO-2026-0042",
        "error_code": "NEGATIVE_AMOUNT_NOT_ALLOWED",
        "error_params": { "field": "quantity" }
      }
    ],
    "rejected": 1,
    "request_id": "b2248a31-0f1b-4a7d-83ce-b29bd5977b4c"
  }
  ```

  ```json 200 (dry_run=true) theme={"theme":{"light":"github-light","dark":"github-dark"}}
  { "dry_run": true, "submitted": 2, "valid": 2 }
  ```
</ResponseExample>

## How it works

1. **Every record is checked.** If any is invalid the **whole** request is rejected with `422` and nothing is
   written; the answer lists every invalid record, so you can fix them all at once.
2. **The purchases are written at once**, with status `in_progress`, in the `x-organization-id` organization. A
   supplier sent by name is looked up among the organization's suppliers (same name and country), or created.
3. **The job classifies and calculates them** in the background, in chunks. A new supplier is classified by its
   activity (SIC/CNAE code, from D\&B) when it carries most of the chunk's spend; the rest get a generic activity.
   Each purchase ends `active`, `in_review` (calculated, with a description match below the confidence threshold) or
   `error`: its job item is then `failed` with `CALCULATION_FAILED`, e.g. when the supplier could not be classified
   or there is no exchange rate for the currency on that date.
4. **When every chunk is done** the job is `completed` (or `completed_with_errors`) and `ingest_job.finished` is sent
   to your webhook endpoints.

### How the emission factor is chosen

| What you send | Factor | `purchase_type` |
| - | - | - |
| `custom_ef_record_id` | Your own factor, per unit of currency or of the physical unit. | `supplier_specific` |
| `matching_method: "description"` and `non_currency_quantity` | A physical-unit factor (ecoinvent) matched to `description`. | `average_data` |
| `matching_method: "description"` | A spend-based factor (EXIOBASE) matched to `description`. | `spend_based` |
| Neither (`matching_method: "supplier"`) | The spend-based factor of the supplier's activity and country (EXIOBASE). | `spend_based` |

A description that cannot be matched falls back to the supplier's factor. With `description`, the match runs in the
background and may finish after the job: such a purchase is calculated with the supplier's factor first and
recalculated when its match arrives.

## Request

### Headers

<ParamField header="x-api-key" type="string" required>
  Your API key (see [Authentication](/api-reference/introduction#authentication)).
</ParamField>

<ParamField header="x-organization-id" type="string" required>
  The organization the purchases belong to, and their suppliers. A record cannot name another organization: send
  each organization's purchases with its own `x-organization-id`.
</ParamField>

<ParamField header="Idempotency-Key" type="string" required>
  1 to 255 characters, unique per batch. A retry with the same key and body returns the original job instead of
  creating the purchases again; the same key with another body is `422 IDEMPOTENCY_KEY_REUSED`, and while the first
  request is still being processed `409 IDEMPOTENCY_KEY_IN_PROGRESS`.
</ParamField>

### Query Parameters

<ParamField query="dry_run" type="boolean" default="false">
  Check only: the same `422` for invalid records, nothing written, no job; `200` with
  `{"dry_run": true, "submitted": n, "valid": n}` when every record is valid.
</ParamField>

<ParamField query="negative_purchases" type="boolean" default="false">
  Accept negative amounts (returns, credit notes). They subtract emissions, never below zero for the organization's
  purchases as a whole: a return the net cannot absorb ends in `error`.
</ParamField>

### Body Parameters

<ParamField body="records" type="array[object]" required>
  The purchases to create, 1 to 5,000. A field not listed here is rejected (`SCHEMA_INVALID`).

  <Expandable title="record">
    <ParamField body="purchase_date" type="date" required>
      The day of the purchase, `YYYY-MM-DD`, from 1970 to today (not in the future). Its year picks the factor's year.
    </ParamField>

    <ParamField body="expense_type" type="string" required>
      `opex` for goods and services consumed in operations (Scope 3 category 1), `capex` for capital goods (category
      2\).
    </ParamField>

    <ParamField body="quantity" type="number">
      What the purchase cost, in `unit_id`. Send it with `unit_id`. At least one amount is required: this one, the
      physical one, or both.
    </ParamField>

    <ParamField body="unit_id" type="uuid">
      The currency of `quantity`: a unit of [`GET /v2/units`](/api-reference/units/list) whose `type` is
      `fiat_currency` (see [resolve a currency](/api-reference/units/list#resolve-the-currency-of-a-purchase)), e.g. euros
      `d3e37f2b-0fc3-4532-82f8-3890ab56ad37`. Other currencies are converted to euros with the purchase date's
      exchange rate.
    </ParamField>

    <ParamField body="non_currency_quantity" type="number">
      How much was bought, in `non_currency_unit_id`. Send it with `non_currency_unit_id`.
    </ParamField>

    <ParamField body="non_currency_unit_id" type="uuid">
      The physical unit of `non_currency_quantity`: a unit of
      [`GET /v2/units?type=non_currency_purchases`](/api-reference/units/list) (kg, l, m, m², m³, unit, kWh, MJ,
      ha).
    </ParamField>

    <ParamField body="supplier_name" type="string">
      The supplier's legal name, up to 255 characters, with `supplier_country`. The organization's supplier with that
      name and country is used, or a new one is created and classified by the job. Required unless you send
      `supplier_id` or `custom_ef_record_id`.
    </ParamField>

    <ParamField body="supplier_country" type="string">
      The supplier's country, ISO 3166-1 alpha-2 (`ES`, `FR`...). It picks the country of the spend-based factor.
    </ParamField>

    <ParamField body="supplier_id" type="uuid">
      A supplier of your organization, instead of `supplier_name`: an `id` from
      [List Purchase Suppliers](/api-reference/purchases/suppliers-list), or the `supplier_id` of a purchase you created
      before.
    </ParamField>

    <ParamField body="description" type="string">
      What was bought, as on the invoice line, up to 500 characters. Required with `matching_method: "description"`.
    </ParamField>

    <ParamField body="matching_method" type="string" default="supplier">
      How the emission factor is chosen: `supplier` (the supplier's activity) or `description` (what was bought, read
      from `description`; see [the table above](#how-the-emission-factor-is-chosen)).
    </ParamField>

    <ParamField body="custom_ef_record_id" type="uuid">
      A record of your [custom emission factor databases](/api-reference/custom-emission-factors/overview) to
      calculate with: find it with [List Records](/api-reference/custom-emission-factors/list-records). Its
      `activity_categories` must include `purchases`. A record per unit of currency needs `quantity` and `unit_id`;
      any other needs `non_currency_unit_id` equal to the record's unit. With it, the supplier is optional.
    </ParamField>

    <ParamField body="client_row_id" type="string">
      Your own reference for the record, up to 255 characters, echoed in errors and in the job's items.
    </ParamField>
  </Expandable>
</ParamField>

## Response

`202 Accepted` with the job (`entity_type: purchases`, `operation: create`) and a `Location` header to it. To read the
result:

* [`GET /v2/ingest-jobs/{id}/items`](/api-reference/ingest-jobs/list-items) has one item per record, in the order
  sent. Its `entity_id` is the purchase's id. A `failed` item carries its `error_code`.
* [List Purchases](/api-reference/purchases/list) returns the purchases with their status and emissions (`co2e`), and
  [Get Purchase](/api-reference/purchases/get) why one is in `error` (`error_messages`).

## Errors

[Problem details](/api-reference/errors). Per-record codes in `errors[].error_code` of a `422 BULK_RECORDS_REJECTED`:

| `error_code` | Cause |
| - | - |
| `SCHEMA_INVALID` | The record does not match the schema: a missing or unknown field (e.g. `organization_id`), an amount without its unit, no amount, a date in the future or before 1970, `supplier_name` without `supplier_country`, `supplier_id` together with `supplier_name`, no supplier and no `custom_ef_record_id`, `matching_method: "description"` without `description`... `error_params.errors` says where. |
| `UNIT_NOT_SUPPORTED` | `unit_id` is not a currency, or `non_currency_unit_id` is not one of the physical units above (`error_params.field` says which). |
| `NEGATIVE_AMOUNT_NOT_ALLOWED` | An amount is negative and `negative_purchases` is not `true`. |
| `REFERENCE_NOT_FOUND` | `supplier_id` does not exist or is not your organization's (both answer the same). |
| `MULTIDB_CUSTOM_RECORD_NOT_FOUND_OR_DISABLED` | `custom_ef_record_id` does not exist, is disabled or is not visible from your organization. |
| `MULTIDB_CUSTOM_RECORD_INCOMPATIBLE_CATEGORY` | `custom_ef_record_id` is not configured for the `purchases` activity category. |
| `PURCHASE_UNIT_INCOMPATIBLE_WITH_EMISSION_FACTOR` | The amounts do not fit `custom_ef_record_id`'s unit: a record per currency without `quantity`, or a physical unit other than the record's. |
| `PURCHASE_NEGATIVE_AMOUNT_NEGATIVE_FACTOR` | A negative amount with a negative `custom_ef_record_id`: the two would add emissions. |

Request-level errors: `401` for a missing or invalid API key, `403 LOGGED_USER_NOT_MEMBER` when the key's user is not a
member of the organization, `422 REQUEST_VALIDATION_FAILED` for a malformed body (no `records`, more than 5,000, no
`Idempotency-Key`), `422 IDEMPOTENCY_KEY_REUSED`, `409 IDEMPOTENCY_KEY_IN_PROGRESS`, and `429` beyond the
[rate limits](/api-reference/rate-limits).

## Limits

| Limit | Value |
| - | - |
| Records per request | 1 to 5,000 |
| Purchases per calculation chunk | 100 |

## Related Endpoints

<CardGroup cols={2}>
  <Card title="Delete Purchases (v2)" icon="trash" href="/api-reference/purchases/delete-v2">
    Delete purchases by id
  </Card>

  <Card title="List Purchase Suppliers" icon="building" href="/api-reference/purchases/suppliers-list">
    Ids for `supplier_id`
  </Card>

  <Card title="Get Ingest Job" icon="magnifying-glass" href="/api-reference/ingest-jobs/get">
    Follow the calculation
  </Card>

  <Card title="List Job Items" icon="list" href="/api-reference/ingest-jobs/list-items">
    The purchase id and outcome of each record
  </Card>

  <Card title="List Purchases" icon="table" href="/api-reference/purchases/list">
    The purchases and their emissions
  </Card>

  <Card title="List Units" icon="ruler" href="/api-reference/units/list">
    Currency and physical unit ids
  </Card>

  <Card title="Custom Emission Factors" icon="flask" href="/api-reference/custom-emission-factors/overview">
    Your own factors for `custom_ef_record_id`
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.