Importing framework answers
The other workflows in this section read data out of BeCause; this one writes it in. Three jobs share the loop:
- Your programme's standings. You run a recognition or scoring programme on top of your distribution channel or marketplace — a ranking in tiers, scores for water, waste, energy — and want each company's standing to live on its BeCause profile as framework answers, keyed to the year it was awarded.
- A chain's head start. You are a hotel chain holding central data — policies, contracts, consumption — and pre-fill certification or RFP frameworks for your properties, so each hotel starts from a completed baseline instead of a blank form.
- A certification programme's field data. Your certification bodies around the world collect consumption data — monthly electricity, water, waste — and send it through you; you import it as the companies' framework answers.
The mechanics are one endpoint: POST /api/v2/bulk/framework-answers/upsert. This walkthrough is the complete loop — one upsert request, a poll, an outcome read — plus a webhook variant that removes the polling.
Before you start
- An API key — Getting started covers creating one, Authentication what it acts as.
- Your framework id, from
GET /api/v1/frameworks. - The ids of the datapoints you will answer, from the framework structure —
GET /api/v1/frameworks/{frameworkId}. For a list datapoint the structure carries each option's id and title; an imported answer names options by id, never by title. - Unit ids from
GET /api/v1/unit-types, for datapoints that declare units — consumption figures above all. - How you will name your companies, decided before the first request. Either BeCause company ids — the
ida framework-answers export or company search returns — or stored values of your own identifier custom property, the id you already hold for each company in your own system.GET /api/v1/custom-propertieslists your properties; the identifier-type ones carryisIdentifier: true. The identifying property must be your own, and String-type.
1. Build and submit the upsert
The upsert is a bulk endpoint: the request is queued, not answered while you wait — see Asynchronous processing. One request carries many companies, many reporting periods and up to 50,000 answers.
curl -X POST https://api.because.eco/api/v2/bulk/framework-answers/upsert \
-H "Authorization: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"frameworkId": "4d8a1f2e-9b3c-4e6d-8a5f-7c2b9e4d1a02",
"companies": [
{
"identifiedBy": { "companyId": "5e9f3a1b-0c2d-4e7f-9a4b-8c5d6e7f0a12" },
"periods": [
{
"periodType": "Yearly",
"year": 2026,
"answers": [
{
"dataPointId": "b3e7c9a1-2d4f-4b8e-9c1a-5e6d7f8a9b03",
"answer": { "list": ["2b3c4d5e-6f7a-4b9c-8d1e-2f3a4b5c6d05"] }
},
{
"dataPointId": "c4f8d0b2-3e5a-4c9f-8d2b-6f7e8a9b0c07",
"answer": { "number": 87.5 }
},
{
"dataPointId": "d5a9e1c3-4f6b-4d0a-9e3c-7a8f9b0c1d08",
"answer": { "number": 74 }
}
]
}
]
},
{
"identifiedBy": {
"customProperty": {
"customPropertyId": "7f1b5c3d-2e4a-4c9b-8d7e-3f4a5b6c7d13",
"value": "H-12345"
}
},
"periods": [
{
"periodType": "Yearly",
"year": 2026,
"month": "April",
"answers": [
{
"dataPointId": "a8d2b4f6-7c9e-4a3d-8b6f-1d2e3f4a5b11",
"answer": { "boolean": true }
}
]
},
{
"periodType": "Monthly",
"year": 2026,
"month": "March",
"answers": [
{
"dataPointId": "e6b0f2d4-5a7c-4e1b-8f4d-9b0a1c2d3e09",
"answer": { "number": 3120 },
"unitId": "f7c1a3e5-6b8d-4f2c-9a5e-0c1d2e3f4a10"
}
]
}
]
}
]
}'
The first company reports a programme standing for 2026: its tier as a list answer — option ids, from the framework structure — and two scores. The second is named by the caller's own identifier and reports a fiscal-year boolean plus one month of electricity.
What each part does:
frameworkId— optional. When present, every stored answer is stamped as answered in this framework, and the framework is assigned to each company for the reported periods — which is what makes the answers appear as that period's assessment on the company's profile instead of floating free. When omitted, answers import without an assignment.identifiedBy— exactly one ofcompanyIdorcustomPropertyper entry, mixable within one request. The custom-property match is trimmed and case-insensitive, and must resolve to exactly one company: zero or several matches fail that entry's rows — never a silent guess.periods— buckets keyed by(periodType, year, month), no duplicate within a company. AYearlybucket covers twelve months from its start month;monthis optional and defaults to January, soyear: 2026, month: "April"means April 2026 through March 2027 — the fiscal-year case. AMonthlybucket is one month,monthrequired.answers— one entry per datapoint, no duplicatedataPointIdwithin a bucket. Exactly one value key insideanswer:text,number,boolean,dateIso, a date range (dateRangeFromDateIso/dateRangeToDateIso, both together) orlistof option ids. Tuple answers are not accepted; a file datapoint takes its URL viatext.unitId— a unit id fromGET /api/v1/unit-types, required per answer when the datapoint declares unit types.- The cap — 50,000 answers per request, summed across every company and period. Split larger imports into several requests.
Structure fails the request; data fails the row. Malformed shape — an empty companies, a bucket with no answers, an answer with no value key or more than one, an unpaired date range (dateRangeFromDateIso without dateRangeToDateIso or vice versa), a Monthly bucket without month, duplicate company targets, buckets or datapoints, the cap exceeded — rejects the whole request as a 400 with named reasons, and nothing is imported. Everything that depends on data — whether the identifier matches a company, whether the datapoint exists and takes that answer type, whether an option id belongs, whether a required unit is present — is checked during processing and fails only the row it belongs to. A bad row never fails the batch.
The response is the handle for everything that follows:
{
"correlationId": "c0a1b2c3-d4e5-4f6a-8b9c-0d1e2f3a4b14"
}
2. Poll the task
curl https://api.because.eco/api/v2/bulk/tasks/c0a1b2c3-d4e5-4f6a-8b9c-0d1e2f3a4b14 \
-H "Authorization: YOUR_API_KEY"
The status advances through Pending, Scheduled and InProgress; poll on an interval — seconds, not milliseconds — until one of the four terminal states:
Success— every answer imported.PartialSuccess— done, but some rows failed;error.rowErrorsnames them.Error— the request failed as a whole;error.generalErrorMessagessays why.Cancelled— the request was cancelled before finishing.
One naming trap: requestType on the status — and in webhook payloads — reports BulkUpsertImportedAnswers, the name of the machinery underneath, not the endpoint's path. Match on it verbatim.
3. Read the outcome
The finished task carries the status and, for every row that failed, the reason:
{
"correlationId": "c0a1b2c3-d4e5-4f6a-8b9c-0d1e2f3a4b14",
"createdAtUtc": "2026-08-04T09:12:41.318Z",
"updatedAtUtc": "2026-08-04T09:13:05.774Z",
"status": "PartialSuccess",
"requestType": "BulkUpsertImportedAnswers",
"result": null,
"error": {
"generalErrorMessages": [],
"errorResults": [],
"rowErrors": [
{
"companyId": null,
"identifiedByValue": "H-12345",
"dataPointId": "a8d2b4f6-7c9e-4a3d-8b6f-1d2e3f4a5b11",
"periodType": "Yearly",
"year": 2026,
"month": "April",
"reason": "NoCompanyMatchedIdentifier",
"message": "No company matched identifier value 'H-12345'."
},
{
"companyId": null,
"identifiedByValue": "H-12345",
"dataPointId": "e6b0f2d4-5a7c-4e1b-8f4d-9b0a1c2d3e09",
"periodType": "Monthly",
"year": 2026,
"month": "March",
"reason": "NoCompanyMatchedIdentifier",
"message": "No company matched identifier value 'H-12345'."
}
]
}
}
How to read it:
resultbelongs to the searches, which deliver result files; an import produces no file, so it is alwaysnullhere.- What landed is the complement of what failed. The task does not list successful rows. Every answer in the request that is not named under
error.rowErrorswas stored — created where no answer existed for that datapoint and period, updated where a different value existed, or left as it was, with its updated-date refreshed, where the same value was already there. Above, the first company's three answers all landed; the second company's two did not. - Failed rows live under
error.rowErrors, each with a machine-readablereasonand a humanmessage. Each row names its company, datapoint and period — the coordinates you sent, so failures join back to your own records.identifiedByValueis the identifier string you sent for acustomProperty-targeted company, andnullfor acompanyId-targeted one; a Yearly bucket sent without amonthreports its start month explicitly,January. Here the identifierH-12345matched no company, so both of that entry's rows failed — and the batch continued. Resolution reasons:CompanyNotFound(acompanyIdthat doesn't match any company),NotPropertyOwner(the identifying property is not yours — unknown and unowned are deliberately indistinguishable),NotAStringProperty(the identifying property is yours but isn't String-typed, so it has no single value to match against),NoCompanyMatchedIdentifier,MultipleCompaniesMatchedIdentifier,DuplicateCompanyRows(two entries in the same request resolved to the same company — for example one naming it bycompanyIdand another by acustomPropertyvalue that happens to belong to it; the request-level check can't catch this, since the two entries never share a literal value to compare). Beyond resolution, a row fails when its datapoint is unknown or inaccessible to you (DataPointNotFound), the answer variant doesn't match the datapoint's type or the datapoint's type doesn't support import (ValueTypeMismatch,UnsupportedDataPointType), an option id isn't among the datapoint's list options (InvalidListOption), a required unit is missing or invalid (UnitRequired,InvalidUnit), or processing itself hits an anomaly, including a row that never got a matching outcome back (InternalError). - The full per-answer record — each stored answer with its
Created,UpdatedorUnchangedoutcome, the failed rows beside them, and the counts of each — is available to the owning company in the BeCause app under Company manager → Import & export → API → API usage, as a downloadable report for the request. The task itself stays a status record, whatever the size of the import. - How the answers land. Every imported answer records your profile as its data source. Answers land unverified unless your profile is a certifier, in which case they land verified — the certification-programme case.
4. Skip the polling
Instead of polling in step 2, configure a webhook on the four terminal topics. When bulk.task.succeeded (or bulk.task.partially_succeeded) arrives, verify the signature, check data.requestType is BulkUpsertImportedAnswers and that data.correlationId is the id you submitted — the webhook fires for every bulk request the profile submits — and read the outcome from the task call.
Re-running the import
The upsert is safe to repeat: re-sending the same payload changes nothing — an already-stored value is left as it is, beyond refreshing its updated-date — and a changed value stores a new answer version. So the natural correction loop after a PartialSuccess is to fix what the row errors name — above, the H-12345 mapping — and re-send the whole payload: the first company's rows are no-ops and the repaired entry imports. The same property makes scheduled syncs safe to run on an interval; mind the rate limits.