Everything CDQ does happens against the Data Mirror: the copy of your business partner data that CDQ holds. An integration therefore has exactly two jobs. Get records in, and get results back out.
This article covers both directions as a developer meets them: which route to take for writing, how to read incrementally so the integration still works when the dataset is large, and the handful of operational details that account for most of the time lost during a first integration.
In this article, you will understand:
- The three routes for writing records into the mirror, and which one suits which cadence
- The two payload shapes, raw record and CDQ data model, and what each one needs in place first
- How records are identified, and why that makes every write repeatable
- How to read results back out incrementally, with paging and the fields outside the standard model
- The failure modes worth knowing before you hit them
Available in:
- Data Exchange API - the programmatic interface to the mirror
This article assumes the concepts. For what a Data Mirror is, how storages and data sources relate, and how to decide your data source design, start with Data Mirror.
An integration needs four things, all established when the workspace is set up:
| What | Where it comes from |
|---|---|
| API key | Created in the workspace. Sent as the X-API-KEY header on every call. |
| Storage ID | The storage you are integrating with. Appears in the path of most calls. |
| Data source ID | The data source you write to and filter by when reading. |
| A mapping | Needed whenever you send your own columns, as a raw record or in a file. Not needed when you send records already in the CDQ data model. |
Base URL: https://api.cdq.com/data-exchange/rest
A record is identified by external ID + data source, not by the id CDQ assigns it. Sending the same external ID into the same data source twice updates the existing record rather than creating a second one.
That is what makes a write safe to retry, and it is why a failed or partially completed load can simply be sent again.
Three routes, differing in latency, volume and integration effort. They are not exclusive, and most integrations use two of them.
| Route | Shape | Suits |
|---|---|---|
| Upsert API | PUT /v4/storages/{storageId}/businesspartners, carrying either your raw record or a record already in the CDQ data model. | A live or near-live connection, where your system emits an event on create and change. |
| Import job | Upload a CSV or Excel file, then start a job that maps its columns into the data model. | Scheduled bulk loads, initial loads, and any case where data already leaves your system as a file. |
| Upload in Cloud Apps | The same import job, started from the user interface instead of the API. | One-off loads, and getting started before an automated route exists. |
One endpoint, two shapes. This is the choice most integrations get wrong first, because the endpoint accepts both and does something quite different with each.
| Shape | You send | Who owns the mapping | Prerequisite |
|---|---|---|---|
| Raw record | Your own columns, exactly as your system names them, as a JSON string in record. | CDQ. The data source's mapping translates your columns into the data model. | A mapping must be assigned to the data source. |
| CDQ data model | names, addresses, identifiers and the other model attributes. | You. The record is stored as sent. | None. |
Neither is more correct. The raw record keeps the translation in one place that a non-developer can change, and it preserves what your system actually held. The data model shape gives you exact control and needs no setup. A record can even carry both, in which case the model attributes are used and the raw columns are kept alongside them.
The record field holds a JSON string of your own columns, with your own column names:
curl -X PUT \
'https://api.cdq.com/data-exchange/rest/v4/storages/{storageId}/businesspartners' \
-H 'Content-Type: application/json' \
-H 'X-API-KEY: {apiKey}' \
-d '{
"dataSource": "{dataSourceId}",
"featuresOn": ["UPSERT_BY_EXTERNAL_ID"],
"businessPartners": [
{
"externalId": "0000012345",
"record": "{\"Customer number\":\"0000012345\", \"Name\":\"Acme Corporation\", \"Country\":\"DE\", \"City\":\"Berlin\", \"Street\":\"Friedrichstrasse 123\", \"Postal code\":\"10115\"}"
}
]
}'Nothing here is in the CDQ data model. Customer number and Postal code are your names for those columns, and the data source's mapping is what turns them into a business partner. If no mapping is assigned, there is nothing to translate the record with.
This is also the shape to use for a partial update. Sending a record with only the external ID and the one field that changed updates that field and leaves the rest alone.
curl -X PUT \
'https://api.cdq.com/data-exchange/rest/v4/storages/{storageId}/businesspartners' \
-H 'Content-Type: application/json' \
-H 'X-API-KEY: {apiKey}' \
-d '{
"dataSource": "{dataSourceId}",
"featuresOn": ["UPSERT_BY_EXTERNAL_ID"],
"businessPartners": [
{
"externalId": "0000012345",
"names": [
{ "value": "Acme Corporation",
"type": { "technicalKey": "LOCAL" } }
],
"addresses": [
{ "country": { "shortName": "DE" },
"localities": [{ "value": "Berlin", "type": { "technicalKey": "CITY" } }],
"postCodes": [{ "value": "10115", "type": { "technicalKey": "REGULAR" } }],
"thoroughfares": [{ "value": "Friedrichstrasse 123", "type": { "technicalKey": "STREET" } }] }
],
"identifiers": [
{ "value": "DE123456789",
"type": { "technicalKey": "EU_VAT_ID_DE" } }
]
}
]
}'No mapping is involved, and the stored business partner looks exactly like the request.
featuresOn: ["UPSERT_BY_EXTERNAL_ID"] is what makes the call an update rather than an insert. Without it, sending the same external ID a second time can create a second record instead of updating the first. It belongs in every upsert an integration makes.
Where dataSource goes. At the top level it applies to every record in the request, which is the usual case. It can instead be set per business partner, so one request can write to several data sources. If the storage has exactly one data source, it can be omitted. Supplying a data source that does not exist, or omitting it when the storage has more than one, is an error rather than a default.
Batch size. Up to 500 business partners per request.
The raw record shape and the import job both depend on a mapping, so it is worth knowing where that lives and what happens when it changes.
A mapping describes which of your fields becomes which CDQ attribute. It is created once and then assigned to a data source, which is why two source systems with different layouts need two data sources rather than one.
| Step | Where |
|---|---|
| Create the mapping | The Data Mirror Management app, starting from the downloadable example, or the Data Mapper Definition endpoints. |
| Assign it to a data source | Set when the data source is created, and changeable afterwards. |
| Send data | Raw records and import jobs are translated through it on arrival. |
Changing a mapping does not retranslate what is already stored. Records that arrived under the old mapping keep the attributes it produced. To bring them in line, start a transformation job against the data source:
curl -X POST \
'https://api.cdq.com/data-exchange/rest/jobs/transformationjobs' \
-H 'Content-Type: application/json' \
-H 'X-API-KEY: {apiKey}' \
-d '{
"storageId": "{storageId}",
"dataSourceId": "{dataSourceId}",
"featuresOn": ["UPSERT_CHANGES_ONLY"],
"countries": [{ "shortName": "CH" }]
}'Narrowing by country is worth doing when a mapping change only affected some of them, because the job then has less to re-process.
Changing the assigned mapping through the Data Mirror Management app starts this transformation automatically. Changing it through the API does not, so an API-driven change needs the job started explicitly.
A file carries your columns rather than the data model, so like the raw record shape it is translated through the data source's mapping on arrival. The difference is only that the data travels as a file instead of a request body.
The flow is four steps, because the file does not travel through the API itself:
1. POST /files/upload -> returns a pre-signed upload link and a file reference
2. PUT {uploadLink} -> the raw file bytes, sent to the link, without your API key
3. POST /jobs/importjobs -> starts the job, referencing the file and the target data source
4. GET /jobs/importjobs/{id} -> poll until the job reaches a terminal stateTwo things about this flow surprise people:
- Step 2 does not go to CDQ. The pre-signed link points at object storage. Sending your
X-API-KEYto it is both unnecessary and undesirable. - Step 3 is
multipart/form-data, not JSON. The fields are sent as form fields. Setting a JSON content type results in the fields being rejected or ignored.
Writing into a data source that mirrors a system requires the job to upsert by external ID rather than insert, which means telling the job which column holds that ID.
An import job is asynchronous, and reaching a terminal state is not the same as succeeding. Read the record counts on the final poll: a job can finish having failed a subset of its records, and a job that cannot find a column the mapping expects stops rather than importing partial records.
For the step-by-step of each route, see How to synchronize data with the Data Mirror.
This is the half that is easy to under-plan. Writing data in is a one-off piece of work; reading results back is something your integration does for as long as the mirror exists.
Results are read from the data source they were written to:
curl -X GET \
'https://api.cdq.com/data-exchange/rest/v4/storages/{storageId}/businesspartners?dataSource={dataSourceId}&limit=500' \
-H 'X-API-KEY: {apiKey}'Re-downloading the full dataset on every cycle works until the dataset is large, and then it stops working. The modifiedAfter parameter takes an ISO-8601 timestamp and returns only records modified since:
curl -X GET \
'https://api.cdq.com/data-exchange/rest/v4/storages/{storageId}/businesspartners?dataSource={dataSourceId}&modifiedAfter=2026-10-01T09:00:00Z&limit=500' \
-H 'X-API-KEY: {apiKey}'The sequence that makes this safe is to take the timestamp before you start reading, not after you finish:
- Record the current time.
- Download everything modified after the timestamp you stored on the previous run.
- Store the timestamp from step 1 as the new watermark.
Taking the timestamp after the download finishes loses any record that CDQ writes while the download is in progress. On a large result set that window is minutes wide, and the records lost are silently absent rather than reported as an error.
Results are paged. When the response reports more records than the page size, it carries a cursor for the next page, which you pass back as startAfter. Page size defaults to 500 and the maximum is 1,000.
The CDQ data model covers names, addresses, identifiers and the other standard attributes. An engagement often produces more than that - per-field before and after values, duplicate group assignments, review decisions - and those do not all have a home in the standard model.
They are preserved on the record anyway. Requesting the FETCH_RECORD feature returns a record field on each business partner containing the full stored row as JSON, including anything outside the standard attributes:
...&featuresOn=FETCH_RECORDIf your integration needs the detail behind a curation rather than just the curated values, this is where it is.
| Parameter | Effect |
|---|---|
dataSource | Restricts the read to one data source. The usual way each consuming system collects only its own records. |
modifiedAfter / modifiedBefore | Bounds the read by modification time. The basis of incremental retrieval. |
countryCode | Restricts to one country, for phased rollouts. |
externalId | Fetches specific records by their key rather than scanning. |
None of these are conceptually interesting. All of them cost time when they are discovered during an integration rather than before it.
| Area | What to know |
|---|---|
| Excel files | An uploaded workbook should contain exactly one sheet of data. Extra sheets cause import problems. |
| File names | Stick to letters, digits, hyphens, underscores and dots. Square brackets and other special characters are rejected by the underlying storage. |
| Column headers | Must match what the mapping expects, and must not contain dots. A missing column stops the job rather than importing a partial record. |
| Escaping | Fields containing commas, semicolons, line breaks or double quotes have to be quoted. |
| Job status | Poll until a terminal state and read the record counts. Handle both the CANCELED and CANCELLED spellings if you match on the string. |
| Deletions | Removing a record from your system does not remove it from the mirror. Deletion is its own call. |
An integration with the Data Mirror is two directions of travel. Records go in through the upsert API for a live connection, through an import job for bulk and file-shaped data, or through the apps for one-off loads.
The upsert endpoint takes either your raw columns in record, translated by the data source's mapping, or a record already in the CDQ data model, stored as sent. Whichever you choose, UPSERT_BY_EXTERNAL_ID is what makes the call an update rather than an insert, and a mapping change needs a transformation job before it reaches records that are already stored.
Results come back out of the data source they were written to. An integration that reads with modifiedAfter, takes its watermark before the read rather than after it, and pages through the result will keep working as the dataset grows. FETCH_RECORD is where the detail lives when the standard attributes are not enough.
- Data Mirror
- How to synchronize data with the Data Mirror
- Uploading CSV/XLS data
- Uploading CSV/XLS with Request Upload
- How to Define and Use Data Transformation Definitions
- Data Exchange API
We are constantly working on providing an outstanding user experience with our products. Please share your opinion about this tutorial!