Custom data implementation planner
Standard objects arrive with prebuilt mappings. Custom data is where implementations break: the wrong stream category, a missing primary key, formula fields that go quietly stale, and everything with no connector at all. Declare your fields and this works out the rest.
Verified against Salesforce documentation on .
Stream category
Engagement
Claim_Date__c is a point in time, which makes this a time series. Engagement records relate to a person through a foreign key rather than by carrying identity themselves.
Decide this before you create the stream
One thing is documented as a one-way door on this path: do not change the CRM object API name after the stream is created.
The category is chosen when the stream is created, and we have found no Salesforce documentation on whether it can be changed afterwards. Every implementation we have worked on has treated it as settled at creation: getting it wrong means deleting the stream and rebuilding every mapping on top of it, so decide it now rather than discovering it later.
4 findings, none of them a blocker.
Warning · 1 formula field that can go stale
Formulas are refreshed during a full or incremental refresh and are recalculated if a record has changed.
A formula field's value changing does not update SystemModstamp or LastModifiedDate: derived fields have no create or update calls.
Incremental refresh runs every 10 minutes, starting after a full refresh, and periodic full refresh is disabled by default in new data streams.
Putting those three together is our reading, not a quotation: a formula whose inputs move on another record produces no record modification for the incremental refresh to detect, so Days_Open__c can hold a stale value until the record is edited for some other reason or somebody runs a full refresh. Materialise it into a real field if a segment depends on it.
Days_Open__c
Warning · This stream cannot run in streaming mode
Including batch-ingested standard or custom formula fields in a CRM data stream changes the processing mode from streaming to batch.
Days_Open__c is what costs you streaming here. If low latency matters more than the derived value does, move it out of the stream.
Days_Open__c
Warning · 1 relationship that can resolve to nothing
Contact__c carries the related record Id and nothing else. Every related object has to be ingested as well, or the relationship saves and then resolves to nothing. In our experience that is the most common reason a custom object lands and never appears against a profile.
Contact__c
Warning · 1 field you probably cannot segment on
Notes__c is prose or a delimited list rather than a value. Multi-select picklists are semicolon delimited at the Salesforce platform level, which is corroborated rather than documented, and how Data 360 types or filters either shape is not published at all. If anybody expects to target on it, derive a flag upstream before this ships rather than finding out in a segment builder.
Notes__c
Field mapping plan
Salesforce does not publish what the CRM connector turns each Salesforce field type into. We are not going to guess one, so the Data 360 type column below is empty and the row says what is documented about the field instead. That gap is listed under the open questions at the bottom of this page.
| Field | Declared type | Data 360 type | Role in the model | What to watch |
|---|---|---|---|---|
Claim_Id__c | Auto Number | Not documented | Primary key | Nothing documented that is specific to this type. |
Contact__c | Lookup | Not documented | Foreign key | A lookup carries the related record Id and nothing more, so the related object has to be ingested too or the relationship resolves to nothing. That is our experience rather than a documented behaviour. |
Claim_Date__c | Date/Time | Not documented | Event timestamp | Nothing documented that is specific to this type. |
Status__c | Picklist | Not documented | Attribute | Nothing documented that is specific to this type. |
Claim_Amount__c | Currency | Not documented | Measure | Each record's three-character ISO currency code is captured automatically in the system field cdp_sys_record_currency__c on the data lake object and the data model object, and multiple currencies are supported. |
Days_Open__c | Formula | Not documented | Measure | Formulas are refreshed during a full or incremental refresh and are recalculated if a record has changed, and a formula value changing updates neither SystemModstamp nor LastModifiedDate. Including one in a CRM data stream also moves the stream from streaming mode to batch mode. |
Notes__c | Long Text Area | Not documented | Attribute | Prose in a segmentation engine is rarely a filter. Derive a flag upstream rather than shipping the paragraph. Our judgement, not a documented restriction. |
Product_Serial__c | Text | Not documented | Attribute | Nothing documented that is specific to this type. |
Implementation runbook
- Grant the Data 360 integration user read access to Warranty_Claim__c and to every field in the table above. Missing field-level security is the failure that produces no error anywhere: the field simply never appears in the stream.
- Create a data stream on the Salesforce CRM connector, select Warranty_Claim__c, and pick the 8 fields in the table above. Up to 30 objects can be selected in one creation pass, all fields are selected by default, and a validation query that runs longer than 2 minutes fails and retries, so narrow the selection rather than taking the whole object.
- Set the stream category to Engagement. Claim_Date__c is what drives that. Decide this before you create the stream rather than after: see the note above on what is and is not documented about changing it.
- Set the primary key to Claim_Id__c, and confirm it is genuinely unique in production rather than in the sandbox where you tested a subset.
- Set the event time field to Claim_Date__c and check its zone against what segmentation assumes. Our reading, not a quotation: a datetime with no zone is read as 00:00:00 UTC and an abbreviated zone such as CST is rejected as ambiguous. Both come off the article behind the type list rather than from anything Phase 0 recorded, so confirm them before you rely on either.
- Set the record modified field for incremental refresh. Incremental runs every 10 minutes starting after a full refresh, and periodic full refresh is disabled by default in new data streams, so decide deliberately whether to switch it on. It will not catch changes to Days_Open__c.
- Map the fields per the table above, and fix the types before mapping rather than after: changing a field's data type is not possible once the data lake object exists.
- Choose the target data model object. Check the standard engagement ones first: activation targets and prebuilt calculated insights already understand those and understand nothing about a custom one.
- Create the relationship from Contact__c to the data model object for the related object, and ingest the related objects first. A relationship whose target is not ingested saves and then resolves to nothing.
- No match keys here, so this joins to people through Contact__c rather than through the identity ruleset. Do not add it.
- Verify before anybody builds a segment: compare row counts against the source, check the null rate on Claim_Id__c, and trace one real record end to end. Most disappointments here are a mapping nobody checked.
Sample code, generated from your fields
-- Run this before you build the stream.
-- 1. Is the primary key actually unique, and never null?
SELECT COUNT(Id) total,
COUNT_DISTINCT(Claim_Id__c) distinct_keys
FROM Warranty_Claim__c
-- If total and distinct_keys differ, your primary key is not one.
-- If they agree here but not in production, you tested in a sandbox
-- against a subset. Check production.
-- 2. How much of this is actually recent?
SELECT CALENDAR_YEAR(Claim_Date__c) yr, COUNT(Id)
FROM Warranty_Claim__c
GROUP BY CALENDAR_YEAR(Claim_Date__c)
ORDER BY CALENDAR_YEAR(Claim_Date__c) DESC
-- Ingest a retention window rather than the whole history.
-- A validation query that runs longer than 2 minutes fails and retries,
-- so an object this cannot summarise quickly is one to filter down
-- before you point a stream at it.Up to 30 objects can be selected per creation pass, all fields are selected by default, and a validation query that runs longer than 2 minutes fails and retries. Do not change the CRM object API name after the stream exists.
Both queries here are ours. They exist because a primary key that is not unique and a table that is mostly ancient are the two things we see sink CRM connector streams, and both are visible before you build anything.
The eight scenarios, and what each one selects
A custom object in Salesforce
Selects Salesforce CRM connector
The connector handles this. Reach for the Ingestion API only when the connector cannot see the object: doing it by choice means owning schema versioning, retries and deletes for no reason.
The connector ingests from the connected org and does not share data back to it, and big objects are not supported at all.
Salesforce field history or an audit object
Selects Ingestion API, bulk
The case that catches people out. History and audit tables are append-only, read-only and enormous, tens of millions of rows for a mid-size org, and they do not behave like ordinary custom objects. Plan a date-partitioned bulk backfill and then a daily delta, and decide a retention window before you start: nobody needs ten years of field history in a segmentation engine, and you pay to ingest every row of it.
ERP, billing or claims system (SAP, NetSuite, in-house)
Selects Ingestion API, bulk
No connector exists and MuleSoft is often licensing you do not have. Bulk for the backfill, then a scheduled delta. The hard part is not the transport: it is agreeing a stable primary key with a team that has never had to expose one.
Product, app or device events in real time
Selects Ingestion API, streaming
Streaming, because latency is the whole point. Watch the volume: events are usually the largest single line on the credit bill, and most teams stream far more than they segment on. Sample or aggregate before ingesting if the raw firehose has no consumer.
If it has to be faster than streaming, an existing Ingestion API integration can ingest in real time once the data lake object's mapped data model object is a member of a real-time data graph.
Consent and preferences from a CMP
Selects Ingestion API, streaming
Streaming, because a withdrawn consent that arrives tomorrow is a compliance incident today. Treat deletes as first-class here.
A record that simply vanished at the source is invisible to the Ingestion API: a suppression has to be sent to the delete endpoint explicitly, at most 200 records a call, and when the stream has a record modified field configured the delete applies only when that field is less than the current timestamp.
Partner or enrichment vendor feed
Selects Cloud storage or SFTP drop
Vendors send files. Take them as files rather than building an API in front of a nightly drop. The work is in naming, manifests and knowing whether each file is a full replace or a delta: get that wrong and you either double-count or silently lose records.
A warehouse table with no zero-copy support
Selects Ingestion API, bulk
If it were Snowflake, BigQuery, Databricks or Redshift you would federate it: the zero-copy or ingest tool in this suite is the one to run first. Without that, bulk export and ingest, and revisit the decision whenever the platform adds a connector, because this is the kind of pipeline that outlives its own justification.
Point of sale or transaction log
Selects Ingestion API, bulk
High volume, append-only, and almost always needed for segmentation rather than real-time journeys, which makes it a scheduled bulk load and not a stream. Match the cadence to the decision it feeds, not to how fast the data is produced.
The four sources
Salesforce CRM connector
Declares fields in the Salesforce field type vocabulary
The object lives in Salesforce and the connector can see it.
The connector ingests from the connected org and sends back only data actions. Big objects are not supported at all, so an object that is one has to come through the Ingestion API instead.
Ingestion API, bulk
Declares fields in the Ingestion API schema vocabulary
Large volumes, job based. Backfills and scheduled deltas.
A job is opened, CSV files are uploaded against it one at a time, and nothing is processed until the job is patched to UploadComplete. A job can finish having rejected rows, so read numberRecordsFailed rather than trusting the state.
Ingestion API, streaming
Declares fields in the Ingestion API schema vocabulary
Small payloads, near real time, one record set per call.
A 202 Accepted means queued, not stored: processing is asynchronous and runs approximately every 3 minutes, and after either kind of ingestion you should allow a minimum of 30 seconds for caches before the data is queryable.
Cloud storage or SFTP drop
Declares fields in the Ingestion API schema vocabulary
Files land on S3, Blob, GCS or SFTP and Data 360 collects them on a schedule. You do not push.
The three stream categories
A stream carries one category, chosen per object when it is created. The planner recommends one from the declared roles: an event timestamp makes it Engagement, match keys with no timestamp make it Profile, and neither makes it Other. You can override the recommendation, and you should decide it before you create the stream.
- Profile
- Engagement
- Other
Every blocker, warning and note the planner computes
| Rule | Severity | Fires when |
|---|---|---|
no-primary-key | Blocker | No live field carries the Primary key role. |
engagement-without-timestamp | Blocker | The chosen category is Engagement and no live field carries the Event timestamp role. |
nothing-to-attach-to | Blocker | The chosen category is Engagement and there is neither a foreign key nor a match key. |
forbidden-role-for-type | Blocker | A schema-source field is in a role its Data 360 type cannot take. |
checkbox-primary-key | Blocker | A Salesforce checkbox is the primary key on the CRM connector. |
invalid-field-name | Blocker | A field name breaks one of the Ingestion API schema naming rules. |
invalid-object-name | Blocker | The object name is empty, over 79 characters, or outside the allowed character set. |
formula-stale | Warning | A live formula field on the CRM connector. |
formula-forces-batch | Warning | A live formula field on the CRM connector. |
rollup-stale | Warning | A live roll-up summary field on the CRM connector. |
dangling-relationship | Warning | A foreign key role, or a lookup or master-detail type. |
not-filterable | Warning | A long text area or a multi-select picklist on the CRM connector. |
you-own-the-plumbing | Warning | Any source other than the CRM connector. |
history-volume | Warning | The field history scenario. |
type-coerced-by-source | Note | A field type had to move because the source changed vocabulary. |
Field types, and the one table Salesforce does not publish
On the Ingestion API and file paths you declare the type yourself, and there are exactly nine to choose from. On the CRM connector the object already has Salesforce field types, and what the connector turns each of them into is not in public documentation. We are not going to guess it: the second table below is empty in that column on purpose, and the question is listed under the open questions on this page.
The nine Ingestion API schema types
| Schema type | Data 360 type |
|---|---|
boolean | boolean |
date | date |
datetime | datetime |
email | |
number | number |
phone | phone |
percent | percent |
text | text |
URL | URL |
The nineteen Salesforce field types
| Salesforce type | Data 360 type |
|---|---|
| Text | Not documented |
| Auto Number | Not documented |
| Not documented | |
| Phone | Not documented |
| URL | Not documented |
| Picklist | Not documented |
| Multi-select Picklist | Not documented |
| Long Text Area | Not documented |
| Number | Not documented |
| Percent | Not documented |
| Currency | Not documented |
| Checkbox | Not documented |
| Date | Not documented |
| Date/Time | Not documented |
| Lookup | Not documented |
| Master-Detail | Not documented |
| Formula | Not documented |
| Roll-Up Summary | Not documented |
| Geolocation | Not documented |
A worked runbook and sample code, per source
Salesforce CRM connector
- Grant the Data 360 integration user read access to Example_Object__c and to every field in the table above. Missing field-level security is the failure that produces no error anywhere: the field simply never appears in the stream.
- Create a data stream on the Salesforce CRM connector, select Example_Object__c, and pick the 4 fields in the table above. Up to 30 objects can be selected in one creation pass, all fields are selected by default, and a validation query that runs longer than 2 minutes fails and retries, so narrow the selection rather than taking the whole object.
- Set the stream category to Engagement. Occurred_At__c is what drives that. Decide this before you create the stream rather than after: see the note above on what is and is not documented about changing it.
- Set the primary key to Record_Key__c, and confirm it is genuinely unique in production rather than in the sandbox where you tested a subset.
- Set the event time field to Occurred_At__c and check its zone against what segmentation assumes. Our reading, not a quotation: a datetime with no zone is read as 00:00:00 UTC and an abbreviated zone such as CST is rejected as ambiguous. Both come off the article behind the type list rather than from anything Phase 0 recorded, so confirm them before you rely on either.
- Set the record modified field for incremental refresh. Incremental runs every 10 minutes starting after a full refresh, and periodic full refresh is disabled by default in new data streams, so decide deliberately whether to switch it on.
- Map the fields per the table above, and fix the types before mapping rather than after: changing a field's data type is not possible once the data lake object exists.
- Choose the target data model object. Check the standard engagement ones first: activation targets and prebuilt calculated insights already understand those and understand nothing about a custom one.
- Create the relationship from Party__c to the data model object for the related object, and ingest the related objects first. A relationship whose target is not ingested saves and then resolves to nothing.
- No match keys here, so this joins to people through Party__c rather than through the identity ruleset. Do not add it.
- Verify before anybody builds a segment: compare row counts against the source, check the null rate on Record_Key__c, and trace one real record end to end. Most disappointments here are a mapping nobody checked.
Validate the source
-- Run this before you build the stream.
-- 1. Is the primary key actually unique, and never null?
SELECT COUNT(Id) total,
COUNT_DISTINCT(Record_Key__c) distinct_keys
FROM Example_Object__c
-- If total and distinct_keys differ, your primary key is not one.
-- If they agree here but not in production, you tested in a sandbox
-- against a subset. Check production.
-- 2. How much of this is actually recent?
SELECT CALENDAR_YEAR(Occurred_At__c) yr, COUNT(Id)
FROM Example_Object__c
GROUP BY CALENDAR_YEAR(Occurred_At__c)
ORDER BY CALENDAR_YEAR(Occurred_At__c) DESC
-- Ingest a retention window rather than the whole history.
-- A validation query that runs longer than 2 minutes fails and retries,
-- so an object this cannot summarise quickly is one to filter down
-- before you point a stream at it.Up to 30 objects can be selected per creation pass, all fields are selected by default, and a validation query that runs longer than 2 minutes fails and retries. Do not change the CRM object API name after the stream exists.
Both queries here are ours. They exist because a primary key that is not unique and a table that is mostly ancient are the two things we see sink CRM connector streams, and both are visible before you build anything.
Check field access
# Run this AS THE INTEGRATION USER, not as yourself. sf org login web --alias prod # The fields that user can actually read: sf sobject describe --sobject Example_Object__c --target-org prod \ --json | jq -r '.result.fields[] | select(.accessible==true) | .name' # Diff that against the fields you planned to map: # Record_Key__c # Party__c # Occurred_At__c # Amount__c # Anything missing is a field-level security problem, not a Data 360 # problem. Fix it in the permission set before you continue. # # And if this object is a big object, stop: the CRM connector does not # support them. It has to come through the Ingestion API instead.
The CRM connector ingests from the connected org and sends back only data actions, and big objects are not supported at all.
Running the describe as the integration user rather than as yourself is the whole point of this tab. In our experience a field the integration user cannot read is the most common silent failure on this path: the field never appears in the stream and no error is raised anywhere.
Refresh and derived fields
# Not code. The two settings that decide whether this stream stays true. # # Incremental refresh runs every 10 minutes, starting after a full # refresh. Periodic full refresh is disabled by default in new data # streams, and its interval is configurable once you enable it. # # Formulas are refreshed during a full or incremental refresh and are # recalculated if a record has changed. A formula value changing does # not update SystemModstamp or LastModifiedDate. # # Our reading of those two together: a formula whose inputs moved on # another record leaves nothing for an incremental refresh to detect, # so with periodic full refresh off by default the stale value can sit # there indefinitely. # # No formula or roll-up fields in this set, so nothing here is exposed # to that. # # Including a batch-ingested formula field in a CRM data stream also # changes the processing mode from streaming to batch, so a derived # field can cost you low latency as well as freshness.
Incremental refresh runs every 10 minutes starting after a full refresh, and periodic full refresh is disabled by default in new data streams. Formulas are refreshed during a full or incremental refresh and are recalculated if a record has changed, but a formula value changing updates neither SystemModstamp nor LastModifiedDate. Including a batch-ingested formula field in a CRM data stream also changes the processing mode from streaming to batch.
The conclusion below is our reading of those four facts put together rather than a sentence Salesforce publishes.
Ingestion API, bulk
- Create a connected app with OAuth enabled and the cdp_ingest_api and api scopes, plus refresh_token or offline_access. The token flow has two hops and the second one is the step everyone misses: see the Authenticate tab below.
- Write the OpenAPI 3.0 schema for Example_Object and upload it against an Ingestion API connector named Example_Object_Stream. Field names in that file are the contract: an object cannot be deleted from a schema once uploaded, a field cannot be removed once added, and an existing field's data type cannot change. Argue about the names now.
- Create a data stream from that Ingestion API source and select Example_Object. There is one data stream per object per connection.
- Set the stream category to Profile. That is what lets these records enter identity resolution. Decide this before you create the stream rather than after: see the note above on what is and is not documented about changing it.
- Set the primary key to record_key, and confirm it is genuinely unique in production rather than in the sandbox where you tested a subset.
- Run the backfill before switching on the delta, partitioned by date so a failure at 80 per cent has a restart point. A job takes at most 100 files uploaded one at a time, and nothing is processed until you patch the job to UploadComplete.
- Map the fields per the table above, and fix the types before mapping rather than after: changing a field's data type is not possible once the data lake object exists.
- Map to the standard Individual or Account data model object wherever the fields correspond. Custom ones are easy to create and expensive to live with.
- Add party_email to the identity resolution ruleset and set match precedence deliberately. Test against a known duplicate set before enabling it: over-matching is far harder to unpick than under-matching.
- Decide how deletions reach Data 360. There is no detection of a record that vanished on an API feed: a suppression, an erasure or a cancelled transaction has to be sent to the delete endpoint explicitly, at most 200 records a call, keyed on record_key.
- Verify before anybody builds a segment: compare row counts against the source, check the null rate on record_key, and trace one real record end to end. Most disappointments here are a mapping nobody checked.
Schema (YAML)
# Uploaded once, when you create the Ingestion API source.
# Field names here are THE CONTRACT: a field cannot be removed once
# added and an existing field's type cannot change, so argue about
# them now rather than after the first load.
openapi: 3.0.3
info:
title: Example_Object
version: 1.0.0
components:
schemas:
Example_Object:
type: object
required:
- record_key
properties:
record_key:
type: string
party_email:
type: string
occurred_at:
type: string
format: date-time
amount:
type: numberThe schema is an OpenAPI 3.0.x file with a .yml or .yaml extension. No nested objects, at most 1,000 fields per object, object names at most 79 characters and field names at most 39. An object cannot be deleted from a schema once uploaded, a field cannot be removed once added, an existing field's data type cannot change, and an object intended for the engagement category must contain a datetime field.
Two things below are ours rather than quoted. The schema file accepts nine field types, boolean, date, datetime, email, number, phone, percent, text and URL, which is on the schema requirements page but is not among the facts Phase 0 recorded in a form we can cite. And how each of those names is spelled as an OpenAPI type and format pair is OpenAPI's own convention rather than something the Salesforce page states. Check both against the schema requirements page before you upload.
Authenticate
#!/usr/bin/env bash # Two hops. Everyone forgets the second one. # # The connected app needs OAuth enabled with the cdp_ingest_api and api # scopes, plus refresh_token or offline_access. # Hop 1: a Salesforce access token. The JWT bearer flow is the one # documented for a server to server integration; the endpoint path and # the grant type string below are NOT documented in anything recorded # for this tool, so both are variables. Read them off the Get Started # page linked under Sources. ACCESS_TOKEN=$(curl -s "$TOKEN_URL" \ -d grant_type="$JWT_GRANT" \ -d assertion="$SIGNED_JWT" | jq -r .access_token) # Hop 2: exchange it at your instance_url for a Data 360 token. # # Same again, and one step further: the parameter NAMES below are ours # too. What was recorded is that the first token is exchanged at the # instance_url and that the response's instance_url is the tenant # endpoint. The path, the grant type and subject_token were not, so # check all five strings on that page before you run this. A wrong path # pasted into a script is worse than a look-up. DC=$(curl -s "$TOKEN_EXCHANGE_URL" \ -d grant_type="$TOKEN_EXCHANGE_GRANT" \ -d subject_token="$ACCESS_TOKEN" \ -d subject_token_type=urn:ietf:params:oauth:token-type:access_token) DC_TOKEN=$(echo "$DC" | jq -r .access_token) DC_HOST=$(echo "$DC" | jq -r .instance_url) # DC_HOST is a DIFFERENT host from your org: it is the tenant specific # Ingestion API endpoint, and every call below uses it. # # Token lifetime follows the connected app's session timeout policy. # Refresh on a 401 rather than on a timer, and make sure the refresh # is not racing your own workers.
Set up an Ingestion API connector and a connected app with OAuth enabled, with the cdp_ingest_api and api scopes plus refresh_token or offline_access. Acquire a Salesforce access token, with the OAuth 2.0 JWT bearer flow documented for server-to-server integration, then exchange it at your instance_url for a Data 360 access token. The instance_url in the exchange response is the tenant-specific Ingestion API endpoint, and token lifetime follows the connected app's session timeout policy.
Everything else in the script below is ours and needs checking against that page before you run it: the hop 1 token endpoint path, the JWT bearer grant type string, the exchange endpoint path, the exchange grant type, and the subject_token and subject_token_type parameter names. Phase 0 recorded the shape of this flow and none of those strings, so the two paths and the exchange grant type are left as variables rather than guessed, and the parameter names are written out only because a script with no parameters at all would not communicate the shape.
CSV payload
record_key,party_email,occurred_at,amount EXAMPLE-001,[email protected],2026-09-08T14:22:00.000Z,129.99 # The header must match the schema field names exactly, case included. # Empty means "no value". Datetimes are ISO 8601 UTC in Zulu form. # # Each file is at most 150 MB and a job takes at most 100 files, so # partition a backfill by date rather than sending one enormous file. # One job per month or per week gives you a restart point when # something fails at eighty per cent, and something will.
Bulk CSV is UTF-8, RFC 4180, comma delimited only, and the header row must exactly match the data source object's field names including case. Empty cells become null, dates are yyyy-MM-dd and datetimes are ISO 8601 UTC in Zulu form. An update is a full replace: there are no patch semantics. A file is at most 150 MB and a job takes at most 100 of them, uploaded one at a time.
Bulk job lifecycle
#!/usr/bin/env bash
set -euo pipefail
# The documented rate limit, quoted exactly as the page phrases it:
#
# "Number of Requests or Jobs Allowed per Hour: 20"
#
# It does not say whether that counts jobs, uploads or every request,
# so plan against the strictest reading and do not restate it.
# Concurrent jobs or requests: 5.
# 1. Open a job. "upsert" keys on the primary key in your schema, which
# makes a replay safe. Append-only history can use "insert", but a
# replay then duplicates: choose deliberately.
JOB=$(curl -sS -X POST "$DC_HOST/api/v1/ingest/jobs" \
-H "Authorization: Bearer $DC_TOKEN" \
-H "Content-Type: application/json" \
-d '{"object":"Example_Object","sourceName":"Example_Object_Stream","operation":"upsert"}' \
| jq -r .id)
# 2. Upload the CSV files, one per request, against the SAME job.
for f in ./out/example_object_*.csv; do
curl -sS -X PUT "$DC_HOST/api/v1/ingest/jobs/$JOB/batches" \
-H "Authorization: Bearer $DC_TOKEN" \
-H "Content-Type: text/csv" \
--data-binary @"$f"
done
# 3. Close it. Nothing is processed until you do.
curl -sS -X PATCH "$DC_HOST/api/v1/ingest/jobs/$JOB" \
-H "Authorization: Bearer $DC_TOKEN" \
-H "Content-Type: application/json" \
-d '{"state":"UploadComplete"}'
# 4. Poll, then read the failure count. A job can reach JobComplete
# having rejected rows.
curl -sS "$DC_HOST/api/v1/ingest/jobs/$JOB" \
-H "Authorization: Bearer $DC_TOKEN" \
| jq '{state,numberRecordsProcessed,numberRecordsFailed}'
# Open or UploadComplete jobs older than 7 days are deleted from the
# queue. After ingestion, allow a minimum of 30 seconds for caches
# before you query 4 fields' worth of anything.A job is created with an object, a source name and an operation of upsert or delete; files are uploaded one per request with Content-Type text/csv; the job is closed by patching its state to UploadComplete or aborted by patching it to Aborted. The states are Open, UploadComplete, InProgress, JobComplete, Failed and Aborted, an aborted job is not processed and its uploaded data is deleted, and a job can complete having rejected rows, so read numberRecordsFailed. Open or UploadComplete jobs older than 7 days are deleted from the queue, 5 jobs or requests may run concurrently, and after ingestion you should allow a minimum of 30 seconds for caches before querying.
The per-hour limit is quoted below exactly as the documentation phrases it, and deliberately not restated. It does not define whether it counts jobs, uploads or all requests, and any tidier sentence we wrote would be an invention.
Ingestion API, streaming
- Create a connected app with OAuth enabled and the cdp_ingest_api and api scopes, plus refresh_token or offline_access. The token flow has two hops and the second one is the step everyone misses: see the Authenticate tab below.
- Write the OpenAPI 3.0 schema for Example_Event and upload it against an Ingestion API connector named Example_Event_Stream. Field names in that file are the contract: an object cannot be deleted from a schema once uploaded, a field cannot be removed once added, and an existing field's data type cannot change. Argue about the names now.
- Create a data stream from that Ingestion API source and select Example_Event. There is one data stream per object per connection.
- Set the stream category to Engagement. occurred_at is what drives that. Decide this before you create the stream rather than after: see the note above on what is and is not documented about changing it.
- Set the primary key to record_key, and confirm it is genuinely unique in production rather than in the sandbox where you tested a subset.
- Set the event time field to occurred_at and check its zone against what segmentation assumes. Our reading, not a quotation: a datetime with no zone is read as 00:00:00 UTC and an abbreviated zone such as CST is rejected as ambiguous. Both come off the article behind the type list rather than from anything Phase 0 recorded, so confirm them before you rely on either.
- Send a single record and confirm it lands end to end before pointing anything larger at it. A 202 means queued rather than stored, processing is asynchronous and runs approximately every 3 minutes, and you should allow a minimum of 30 seconds for caches before querying.
- Map the fields per the table above, and fix the types before mapping rather than after: changing a field's data type is not possible once the data lake object exists.
- Choose the target data model object. Check the standard engagement ones first: activation targets and prebuilt calculated insights already understand those and understand nothing about a custom one.
- Create the relationship from party_key to the data model object for the related object, and ingest the related objects first. A relationship whose target is not ingested saves and then resolves to nothing.
- No match keys here, so this joins to people through party_key rather than through the identity ruleset. Do not add it.
- Decide how deletions reach Data 360. There is no detection of a record that vanished on an API feed: a suppression, an erasure or a cancelled transaction has to be sent to the delete endpoint explicitly, at most 200 records a call, keyed on record_key.
- Verify before anybody builds a segment: compare row counts against the source, check the null rate on record_key, and trace one real record end to end. Most disappointments here are a mapping nobody checked.
Schema (YAML)
# Uploaded once, when you create the Ingestion API source.
# Field names here are THE CONTRACT: a field cannot be removed once
# added and an existing field's type cannot change, so argue about
# them now rather than after the first load.
openapi: 3.0.3
info:
title: Example_Event
version: 1.0.0
components:
schemas:
Example_Event:
type: object
required:
- record_key
- occurred_at
properties:
record_key:
type: string
party_key:
type: string
occurred_at:
type: string
format: date-time
amount:
type: numberThe schema is an OpenAPI 3.0.x file with a .yml or .yaml extension. No nested objects, at most 1,000 fields per object, object names at most 79 characters and field names at most 39. An object cannot be deleted from a schema once uploaded, a field cannot be removed once added, an existing field's data type cannot change, and an object intended for the engagement category must contain a datetime field.
Two things below are ours rather than quoted. The schema file accepts nine field types, boolean, date, datetime, email, number, phone, percent, text and URL, which is on the schema requirements page but is not among the facts Phase 0 recorded in a form we can cite. And how each of those names is spelled as an OpenAPI type and format pair is OpenAPI's own convention rather than something the Salesforce page states. Check both against the schema requirements page before you upload.
Authenticate
#!/usr/bin/env bash # Two hops. Everyone forgets the second one. # # The connected app needs OAuth enabled with the cdp_ingest_api and api # scopes, plus refresh_token or offline_access. # Hop 1: a Salesforce access token. The JWT bearer flow is the one # documented for a server to server integration; the endpoint path and # the grant type string below are NOT documented in anything recorded # for this tool, so both are variables. Read them off the Get Started # page linked under Sources. ACCESS_TOKEN=$(curl -s "$TOKEN_URL" \ -d grant_type="$JWT_GRANT" \ -d assertion="$SIGNED_JWT" | jq -r .access_token) # Hop 2: exchange it at your instance_url for a Data 360 token. # # Same again, and one step further: the parameter NAMES below are ours # too. What was recorded is that the first token is exchanged at the # instance_url and that the response's instance_url is the tenant # endpoint. The path, the grant type and subject_token were not, so # check all five strings on that page before you run this. A wrong path # pasted into a script is worse than a look-up. DC=$(curl -s "$TOKEN_EXCHANGE_URL" \ -d grant_type="$TOKEN_EXCHANGE_GRANT" \ -d subject_token="$ACCESS_TOKEN" \ -d subject_token_type=urn:ietf:params:oauth:token-type:access_token) DC_TOKEN=$(echo "$DC" | jq -r .access_token) DC_HOST=$(echo "$DC" | jq -r .instance_url) # DC_HOST is a DIFFERENT host from your org: it is the tenant specific # Ingestion API endpoint, and every call below uses it. # # Token lifetime follows the connected app's session timeout policy. # Refresh on a 401 rather than on a timer, and make sure the refresh # is not racing your own workers.
Set up an Ingestion API connector and a connected app with OAuth enabled, with the cdp_ingest_api and api scopes plus refresh_token or offline_access. Acquire a Salesforce access token, with the OAuth 2.0 JWT bearer flow documented for server-to-server integration, then exchange it at your instance_url for a Data 360 access token. The instance_url in the exchange response is the tenant-specific Ingestion API endpoint, and token lifetime follows the connected app's session timeout policy.
Everything else in the script below is ours and needs checking against that page before you run it: the hop 1 token endpoint path, the JWT bearer grant type string, the exchange endpoint path, the exchange grant type, and the subject_token and subject_token_type parameter names. Phase 0 recorded the shape of this flow and none of those strings, so the two paths and the exchange grant type are left as variables rather than guessed, and the parameter names are written out only because a script with no parameters at all would not communicate the shape.
Send records
# One call, one set of records.
curl -sS -X POST \
"$DC_HOST/api/v1/ingest/sources/Example_Event_Stream/Example_Event" \
-H "Authorization: Bearer $DC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"data": [
{
"record_key": "EXAMPLE-001",
"party_key": "EXAMPLE-001",
"occurred_at": "2026-09-08T14:22:00.000Z",
"amount": "129.99"
}
]
}'
# Expect 202 Accepted. That means QUEUED, not stored: processing is
# asynchronous and runs approximately every 3 minutes, and you should
# allow a minimum of 30 seconds for caches before you query.
#
# Use the schema's field names, not Salesforce API names. A payload
# using API names produces a single row of blank values and no error.
#
# While developing, validate synchronously first:
# POST .../api/v1/ingest/sources/Example_Event_Stream/Example_Event/actions/testRecords are posted to the ingest source endpoint with a body of {"data": [records]}, using the schema's field names rather than Salesforce API names. A 202 Accepted means queued rather than stored, processing is asynchronous and runs approximately every 3 minutes, and after ingestion you should allow a minimum of 30 seconds for caches before querying. There is a synchronous validation endpoint for development that checks a payload before committing it.
Batching and retry
// Batching, upsert semantics and retry are the three things that bite.
//
// There is no records-per-request limit published: the documented
// limit is a 200 KB JSON body, so batch against BYTES rather than
// against a record count somebody guessed.
const MAX_BODY_BYTES = 200 * 1024; // 200 KB, documented
const BACKOFF_BASE_MS = 500; // ours, not a Salesforce limit
async function send(records) {
let batch = [];
for (const record of records) {
const next = [...batch, record];
if (Buffer.byteLength(JSON.stringify({ data: next })) > MAX_BODY_BYTES) {
await flush(batch);
batch = [record];
} else {
batch = next;
}
}
if (batch.length > 0) await flush(batch);
}
async function flush(batch) {
await withRetry(() =>
fetch(`${DC_HOST}/api/v1/ingest/sources/Example_Event_Stream/Example_Event`, {
method: 'POST',
headers: {
Authorization: `Bearer ${dcToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ data: batch }),
})
);
}
async function withRetry(fn, attempts = 5) {
for (let a = 0; a < attempts; a++) {
const res = await fn();
if (res.ok) return res;
if (res.status === 401) { await refreshToken(); continue; }
if (res.status === 429 || res.status >= 500) {
// 429 means reduce request frequency. There are 250 requests per
// second across ALL object endpoints, so back off globally and
// not just on this worker.
await sleep(2 ** a * BACKOFF_BASE_MS + Math.random() * BACKOFF_BASE_MS);
continue;
}
throw new Error(`${res.status} ${await res.text()}`);
}
throw new Error('gave up after retries');
}
// A record that vanished at the source is invisible here. Send a
// delete explicitly, at most 200 records a call.
// Deletes key on record_key.The documented limits are a maximum JSON body of 200 KB per request and 250 total requests per second across all Ingestion API object endpoints, with HTTP 429 meaning reduce request frequency. A delete takes at most 200 records per call.
No records-per-request figure is documented anywhere, so this batches against the documented body size rather than against a record count. The backoff timings are ours.
Cloud storage or SFTP drop
- Create a connected app with OAuth enabled and the cdp_ingest_api and api scopes, plus refresh_token or offline_access. The token flow has two hops and the second one is the step everyone misses: see the Authenticate tab below.
- Write the OpenAPI 3.0 schema for Example_Feed and upload it against an Ingestion API connector named Example_Feed_Stream. Field names in that file are the contract: an object cannot be deleted from a schema once uploaded, a field cannot be removed once added, and an existing field's data type cannot change. Argue about the names now.
- Create a data stream from that Ingestion API source and select Example_Feed. There is one data stream per object per connection.
- Set the stream category to Profile. That is what lets these records enter identity resolution. Decide this before you create the stream rather than after: see the note above on what is and is not documented about changing it.
- Set the primary key to record_key, and confirm it is genuinely unique in production rather than in the sandbox where you tested a subset.
- Schedule the stream after the vendor's delivery window with slack for their retries, then verify the row count actually moved. A stream that runs successfully against a file that never landed reports success.
- Map the fields per the table above, and fix the types before mapping rather than after: changing a field's data type is not possible once the data lake object exists.
- Map to the standard Individual or Account data model object wherever the fields correspond. Custom ones are easy to create and expensive to live with.
- Add party_email to the identity resolution ruleset and set match precedence deliberately. Test against a known duplicate set before enabling it: over-matching is far harder to unpick than under-matching.
- Decide how deletions reach Data 360. There is no detection of a record that vanished on an API feed: a suppression, an erasure or a cancelled transaction has to be sent to the delete endpoint explicitly, at most 200 records a call, keyed on record_key.
- Verify before anybody builds a segment: compare row counts against the source, check the null rate on record_key, and trace one real record end to end. Most disappointments here are a mapping nobody checked.
CSV shape
record_key,party_email,score EXAMPLE-001,[email protected],129.99 # The rules that matter more than they look: # - UTF-8, RFC 4180 quoting, header row always present # - timestamps ISO 8601 in UTC, never local time # - an empty cell means "no value", not "clear this value" # - column order is part of the contract once the stream exists
Everything on this tab is our convention. Salesforce documents CSV requirements for the Ingestion API's bulk path, and a cloud storage or SFTP drop is a different connector whose file conventions Phase 0 found nothing on. We are not going to borrow the bulk rules and present them as this path's.
Naming and manifest
# Partner drops fail on ambiguity about what a file MEANS, not on
# transport. Put the answer in the name.
s3://your-bucket/example_feed/
example_feed_full_YYYYMMDD.csv # full replace
example_feed_delta_YYYYMMDD.csv # incremental
# Decide these three, and write the answers down:
# 1. Is each file a FULL replace or a DELTA? A full file processed as
# a delta double counts. A delta processed as a full silently
# deletes everything absent from it.
# 2. What happens on a missing day? Nothing, or an empty file?
# "Nothing" and "empty" must not mean the same thing.
# 3. Who is paged when a file does not arrive?Everything on this tab is our convention. Salesforce documents CSV requirements for the Ingestion API's bulk path, and a cloud storage or SFTP drop is a different connector whose file conventions Phase 0 found nothing on. We are not going to borrow the bulk rules and present them as this path's.
Collection schedule
# Data 360 collects on a schedule. You do not push. Line the schedule # up AFTER the vendor's delivery window, with slack for their retries. # Vendor delivers 02:00 to 03:00 UTC -> schedule the stream at 04:00 UTC. # Then verify the row count actually moved. A stream that runs # successfully against a file that never landed reports success, and # nothing downstream will tell you otherwise.
Everything on this tab is our convention. Salesforce documents CSV requirements for the Ingestion API's bulk path, and a cloud storage or SFTP drop is a different connector whose file conventions Phase 0 found nothing on. We are not going to borrow the bulk rules and present them as this path's.
What we could not source
Salesforce field type to Data 360 type conversion table for the CRM connector
Tried: help and developer site searches for field type mapping and data type conversions; CRM connector article; Create a Salesforce CRM Data Stream; Data Types in Data 360; Data Types in Field Mappings; KB 005299092; guessed article id c360_a_data_type_conversions.htm (404). Options: derive empirically in our own org and label as observed-on-date, or drop per-type coercion claims from the planner.
Incremental refresh cursor field for the CRM connector
Tried Data Stream Schedule, CRM Connector, CRM Connector Streaming articles. The formula warning survives either way (neither field moves); the roll-up warning depends on it.
Geolocation compound field handling by the CRM connector
Tried CRM connector docs, Data Types in Data 360, KB search. Verify empirically or drop from the mapping table.
Exact meaning of the bulk 20-per-hour limit
Quote the limit verbatim in the tools; do not restate as jobs per day or uploads per hour.
Streaming records-per-second limit
Any records-per-second number would be a derivation, not a documented limit; label it as such if shown.
Sources
- Salesforce CRM ConnectorData 360Read on .
- Salesforce CRM connector direction
- Data Stream Schedule in Data 360Data 360Read on .
- Salesforce CRM connector refresh modes
- Formula fields under CRM connector refresh
- Data Types in Data 360Data 360Read on .
- Data 360 data type system
- Type constraints on roles
- Data Types in Field MappingsData 360Read on .
- Currency ISO code handling
- Spiff: How to Trigger Formula Field Value Updates (KB 005239151)Salesforce (platform KB)Read on .
- Formula fields do not move record timestamps
- CRM Connector StreamingData 360Read on .
- Formula fields disable CRM streaming mode
- Difference between SystemModStamp and LastModifiedDate (KB 000387261)Salesforce (platform KB)Read on .
- Roll-up summary fields and SystemModstamp
- Create a Salesforce CRM Data StreamData 360Read on .
- CRM data stream creation constraints
- Get Started with Ingestion APIData 360Read on . Carries one open question, listed above.
- Auth flow
- Bulk limits
- Streaming limits
- Eventual consistency window
- Exact meaning of the bulk 20-per-hour limit
- Streaming Ingestion WalkthroughData 360Read on .
- Streaming upsert endpoint
- Streaming validation endpoint
- Delete Records (Ingestion API reference)Data 360Read on .
- Streaming delete endpoint
- Bulk Ingestion WalkthroughData 360Read on .
- Bulk job endpoints
- Bulk Ingestion (Ingestion API reference)Data 360Read on .
- Bulk job lifecycle states
- Bulk CSV requirements
- Requirements for Ingestion API Schema FileData 360Read on .
- Schema format
- Schema evolution rules
- Ingest Data into Data 360Data 360Read on .
- Real-time reuse of Ingestion API
Verified against Salesforce documentation on 2026-09-08.
Salesforce ships three releases a year and its pricing artifacts move faster than that. Every fact on this page carries the date it was last checked against Salesforce documentation, and the sources are listed in full at the bottom.