Guides
Real-time Record Writes

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:


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": null clears 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-21

Unknown 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.

KeyWhen to useRisk
emailMost B2C profilesCustomers change emails; creates duplicates if not merged later
customer_idAuthenticated users with a stable internal IDRequires the ID to be present on every write
phoneSMS-first flowsPhone number formatting is fragile — normalize before writing
external_idCRM/ERP integration where you control the keyBest 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:

  1. Buffer and batch — accumulate records and call the batch endpoint instead. 10,000 records in one request counts as one against the batch quota.
  2. Increase concurrency thoughtfully — more concurrent requests doesn't help if they all hit the same token's limit.
  3. 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