Skip to main content

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 id a 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-properties lists your properties; the identifier-type ones carry isIdentifier: 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 of companyId or customProperty per 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. A Yearly bucket covers twelve months from its start month; month is optional and defaults to January, so year: 2026, month: "April" means April 2026 through March 2027 — the fiscal-year case. A Monthly bucket is one month, month required.
  • answers — one entry per datapoint, no duplicate dataPointId within a bucket. Exactly one value key inside answer: text, number, boolean, dateIso, a date range (dateRangeFromDateIso / dateRangeToDateIso, both together) or list of option ids. Tuple answers are not accepted; a file datapoint takes its URL via text.
  • unitId — a unit id from GET /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.rowErrors names them.
  • Error — the request failed as a whole; error.generalErrorMessages says 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:

  • result belongs to the searches, which deliver result files; an import produces no file, so it is always null here.
  • 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.rowErrors was 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-readable reason and a human message. Each row names its company, datapoint and period — the coordinates you sent, so failures join back to your own records. identifiedByValue is the identifier string you sent for a customProperty-targeted company, and null for a companyId-targeted one; a Yearly bucket sent without a month reports its start month explicitly, January. Here the identifier H-12345 matched no company, so both of that entry's rows failed — and the batch continued. Resolution reasons: CompanyNotFound (a companyId that 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 by companyId and another by a customProperty value 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, Updated or Unchanged outcome, 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.