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"
}
]
}'
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"])
{
"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"
}
}
{
"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"
}
{ "dry_run": true, "submitted": 2, "valid": 2 }
Create Purchases (v2)
Create up to 5,000 purchases in one request; suppliers are classified and emissions calculated asynchronously
POST
/
v2
/
purchases
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"
}
]
}'
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"])
{
"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"
}
}
{
"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"
}
{ "dry_run": true, "submitted": 2, "valid": 2 }
← Purchases API
Create one or many purchases of goods and services (Scope 3 category 1, or category 2 for
A description that cannot be matched falls back to the supplier’s factor. With
Request-level errors:
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 reads and edits.
The request checks every record and writes them all at once, then answers 202 Accepted with an
ingest job that classifies and calculates them. Follow it with
GET /v2/ingest-jobs/{id} or the
ingest_job.finished webhook.
Beta. Part of the bulk ingest API, currently in beta. The contract may still change before general availability.
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"
}
]
}'
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"])
{
"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"
}
}
{
"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"
}
{ "dry_run": true, "submitted": 2, "valid": 2 }
How it works
- Every record is checked. If any is invalid the whole request is rejected with
422and nothing is written; the answer lists every invalid record, so you can fix them all at once. - The purchases are written at once, with status
in_progress, in thex-organization-idorganization. A supplier sent by name is looked up among the organization’s suppliers (same name and country), or created. - 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) orerror: its job item is thenfailedwithCALCULATION_FAILED, e.g. when the supplier could not be classified or there is no exchange rate for the currency on that date. - When every chunk is done the job is
completed(orcompleted_with_errors) andingest_job.finishedis 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 |
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
string
required
Your API key (see Authentication).
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.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.Query Parameters
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.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.Body Parameters
array[object]
required
The purchases to create, 1 to 5,000. A field not listed here is rejected (
SCHEMA_INVALID).Show record
Show record
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.string
required
opex for goods and services consumed in operations (Scope 3 category 1), capex for capital goods (category
2).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.uuid
The currency of
quantity: a unit of GET /v2/units whose type is
fiat_currency (see resolve a currency), e.g. euros
d3e37f2b-0fc3-4532-82f8-3890ab56ad37. Other currencies are converted to euros with the purchase date’s
exchange rate.number
How much was bought, in
non_currency_unit_id. Send it with non_currency_unit_id.uuid
The physical unit of
non_currency_quantity: a unit of
GET /v2/units?type=non_currency_purchases (kg, l, m, m², m³, unit, kWh, MJ,
ha).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.string
The supplier’s country, ISO 3166-1 alpha-2 (
ES, FR…). It picks the country of the spend-based factor.uuid
A supplier of your organization, instead of
supplier_name: an id from GET /v1/purchases/suppliers, or the
supplier_id of a purchase you created before.string
What was bought, as on the invoice line, up to 500 characters. Required with
matching_method: "description".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).uuid
A record of your custom emission factor databases to
calculate with: find it with 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.string
Your own reference for the record, up to 255 characters, echoed in errors and in the job’s items.
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}/itemshas one item per record, in the order sent. Itsentity_idis the purchase’s id. Afaileditem carries itserror_code.- List Purchases returns the purchases with their status and emissions (
co2e), and Get Purchase why one is inerror(error_messages).
Errors
Problem details. Per-record codes inerrors[].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. |
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.
Limits
| Limit | Value |
|---|---|
| Records per request | 1 to 5,000 |
| Purchases per calculation chunk | 100 |
Related Endpoints
Delete Purchases (v2)
Delete purchases by id
Get Ingest Job
Follow the calculation
List Job Items
The purchase id and outcome of each record
List Purchases
The purchases and their emissions
List Units
Currency and physical unit ids
Custom Emission Factors
Your own factors for
custom_ef_record_idWas this page helpful?