Real-time Record Writes
Write individual records to the CDP synchronously — confirmed, durable, and ready to query within seconds. This path is designed for event-driven workloads: webhook handlers, form submissions, server-side tracking, and any integration where you need a confirmed write before moving on.
When to use this path:
- You're processing one event at a time (user signup, order placed, profile update)
- You need the write confirmed synchronously before returning to the caller
- Volume is within your plan's single-write rate limit (see rate limits documentation)
When to use something else:
- Bursts or high sustained volume → Batch Record Writes
- Periodic bulk syncs or file-based loads → Bulk File Import
- Backfilling historical data → Bulk File Import
The two write operations
Append
POST /records/{object_name}/append
Always inserts a new row. Never merges. Use this when each event is independently meaningful and duplicates should be preserved — transaction logs, activity streams, clickstream events.
curl -X POST https://api.experiture.ai/public/v1/records/orders/append \
-H "Authorization: Bearer <your_access_token>" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"record": {
"order_id": "ord_9182",
"customer_email": "jane@example.com",
"total": 129.99,
"currency": "USD",
"placed_at": "2026-04-21T15:30:00Z"
}
}'Upsert
POST /records/{object_name}/upsert
Inserts if no match is found; merges into the existing row if one is. Use this for stateful entities — customer profiles, account records, subscription states — where you want last-write-wins on shared fields.
curl -X POST https://api.experiture.ai/public/v1/records/profiles/upsert \
-H "Authorization: Bearer <your_access_token>" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"record": {
"email": "jane@example.com",
"first_name": "Jane",
"tier": "gold",
"last_seen_at": "2026-04-21T15:30:00Z"
},
"matchKey": "email"
}'Response — 200 OK
{
"success": true,
"data": {
"operation": "upsert",
"objectName": "profiles",
"accepted": true,
"acceptedAt": "2026-04-21T15:30:00Z",
"acceptedRecords": 1,
"matchKey": "email"
}
}accepted: true means the write is durably committed. Return success to your caller as soon as you see this.
Idempotency
Always send an Idempotency-Key. Network failures happen. Without one, a retry on an append creates a duplicate row. Without one on an upsert, a retry can race against another write and corrupt state.
Generate a UUID v4 tied to the logical event identity — not the HTTP attempt:
import uuid, requests
def handle_signup_webhook(event: dict, api_key: str):
# Use the upstream event ID — stable across retries
idempotency_key = event["event_id"]
requests.post(
"https://api.experiture.ai/public/v1/records/profiles/upsert",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
json={
"record": {
"email": event["email"],
"signup_source": event["source"],
"signed_up_at": event["timestamp"],
},
"matchKey": "email",
},
).raise_for_status()A request with the same key + body returns the cached original response for 24 hours. Re-sends are free; you won't double-write.
Field semantics
Null vs. absent:
- Sending
"field": nullclears the value in the CDP. - Omitting the field leaves the existing value unchanged.
- This matters for upserts: only send fields you intend to update.
# WRONG: clears first_name even though you only want to update tier
record = {"email": "jane@example.com", "first_name": None, "tier": "gold"}
# RIGHT: only update the fields you have
record = {"email": "jane@example.com", "tier": "gold"}Timestamps: ISO 8601 with a timezone offset. The API rejects naive datetimes.
✓ 2026-04-21T15:30:00Z
✓ 2026-04-21T11:30:00-04:00
✗ 2026-04-21 15:30:00
✗ 2026-04-21Unknown fields: cause a CDP_ETL.VALIDATION.REQUEST_SCHEMA error. Check the schema first with the Metadata API if you're unsure what fields exist.
matchKey strategies
matchKey is the field used to find an existing row during upsert. Choosing the right key matters.
| Key | When to use | Risk |
|---|---|---|
email | Most B2C profiles | Customers change emails; creates duplicates if not merged later |
customer_id | Authenticated users with a stable internal ID | Requires the ID to be present on every write |
phone | SMS-first flows | Phone number formatting is fragile — normalize before writing |
external_id | CRM/ERP integration where you control the key | Best for system-of-record sync |
If matchKey is omitted on an upsert, the API defaults to the object's configured primary key. For the profiles object, that's typically email unless your workspace is configured differently.
Use the Metadata API to confirm:
curl https://api.experiture.ai/public/v1/metadata/objects/profiles \
-H "Authorization: Bearer <your_access_token>" \
| jq '.data.identityKeys'Retry logic
Retry on 429, 500, 502, 503, 504. Do not retry 4xx errors other than 429 — the request is bad, retrying won't fix it.
import random, time, requests
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
def write_with_retry(url: str, headers: dict, body: dict, max_attempts: int = 5):
for attempt in range(max_attempts):
resp = requests.post(url, headers=headers, json=body)
if resp.status_code < 400:
return resp.json()
if resp.status_code not in RETRYABLE_STATUS:
raise ValueError(f"Non-retryable error {resp.status_code}: {resp.text}")
# Respect Retry-After on 429, otherwise exponential backoff with jitter
retry_after = float(resp.headers.get("Retry-After", 0))
backoff = min(30, (2 ** attempt) + random.random())
time.sleep(max(retry_after, backoff))
raise RuntimeError(f"Exhausted {max_attempts} attempts")Critical: pass the same Idempotency-Key on every retry of the same logical operation. Generating a new key on retry defeats idempotency.
Rate limits
Rate limits for single writes depend on your plan — see rate limits documentation for current values. Check X-RateLimit-Remaining on every response.
If you're hitting limits:
- Buffer and batch — accumulate records and call the batch endpoint instead. 10,000 records in one request counts as one against the batch quota.
- Increase concurrency thoughtfully — more concurrent requests doesn't help if they all hit the same token's limit.
- Request a limit raise for sustained production workloads — email
support@experiture.com.
Common failure modes
CDP_ETL.VALIDATION.REQUEST_SCHEMA — you're sending a field that doesn't exist on the object schema. Fetch the schema first:
curl https://api.experiture.ai/public/v1/metadata/objects/profiles \
-H "Authorization: Bearer <your_access_token>"CDP_ETL.VALIDATION.REQUEST_INVALID with required fields missing — the object has required fields and your record is missing one. Check error.details for which field.
409 conflict — concurrent writes to the same key raced. Safe to retry with backoff; the Idempotency-Key ensures the result is deterministic.
Counts feel stale after a write — expected. The write is accepted immediately, but search indexes and profile counts update within seconds to a few minutes. Don't poll for the record immediately after writing in a test.
Full example — webhook handler
import os, requests
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
API_KEY = os.environ["EXPERITURE_API_KEY"]
BASE_URL = "https://api.experiture.ai/public/v1"
@app.post("/webhooks/stripe")
async def handle_stripe_event(request: Request):
event = await request.json()
# Stripe provides a stable event ID — use it as idempotency key
idempotency_key = event["id"]
if event["type"] == "customer.subscription.updated":
sub = event["data"]["object"]
resp = requests.post(
f"{BASE_URL}/records/profiles/upsert",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
json={
"record": {
"email": sub["customer_email"],
"subscription_status": sub["status"],
"subscription_plan": sub["plan"]["nickname"],
"subscription_updated_at": sub["current_period_start"],
},
"matchKey": "email",
},
)
if resp.status_code in (429, 500, 502, 503, 504):
raise HTTPException(status_code=503, detail="retry")
resp.raise_for_status()
return {"ok": True}See Also
- Batch Record Writes — when you have > 1 record to write at a time
- Identity Resolution — choosing the right matchKey strategy
- Error Handling & Retries — production-grade retry patterns
- Records API reference