upsert_contact

Create or update a contact at the connected provider. Idempotent on email. Auto-creates custom fields the provider hasn't seen before.

Endpoint

POST https://api.vorlek.com/v1/tools/upsert_contact

Auth: Authorization: Bearer vk_(live|test)_…

Input

FieldTypeRequiredNotes
emailstringyesRFC 5321 email format. Trimmed + lowercased before dispatch. Max 320 chars.
first_namestringnoMax 255 chars.
last_namestringnoMax 255 chars.
phonestringnoMax 64 chars. Sent as phone_number to SendGrid.
propertiesobjectnoFree-form custom fields. Values must be string, number, or boolean. Max 50 keys per call. Auto-created at the provider on first use.
providerstringnoOne of sendgrid. Auto-detected when the account has exactly one active connection.

Unknown fields are rejected (strict schema). Pass extras → INVALID_PARAMS.

Example body

{
  "email": "jamie@example.com",
  "first_name": "Jamie",
  "last_name": "Smith",
  "phone": "+15551234567",
  "properties": {
    "plan": "free",
    "loyalty_tier": "gold",
    "trial_days_remaining": 12
  }
}

Output (success)

{
  "status": "success",
  "data": {
    "contact_id": "f1ec8680-...",
    "action": "upserted",
    "fields_auto_created": ["loyalty_tier"],
    "provider": "sendgrid"
  },
  "meta": {
    "request_id": "01JD5K9X7M0Q8V0QY4FYVQ3WZV",
    "queue_time_ms": 2,
    "execution_time_ms": 234,
    "total_latency_ms": 236,
    "quota": {
      "used": 42,
      "limit": 1000,
      "resets_at": "2026-05-01T00:00:00.000Z"
    }
  },
  "tip": null
}
FieldNotes
data.contact_idProvider-native id. For SendGrid in Phase 1 this is the job_id from PUT /v3/marketing/contacts (Phase 2 may resolve to the canonical contact id).
data.actionOne of created, updated, upserted. SendGrid's PUT cannot distinguish create from update, so it always returns upserted. Phase 2 adapters (Mailchimp, Klaviyo) return the narrower values. Tolerate all three.
data.fields_auto_createdCustom-field names that did not exist at the provider before this call and were created on the agent's behalf. Empty array if everything was already mapped.
meta.request_idULID. Echo back in support requests.
meta.quota.check_skippedOptional. true only when both Redis and Postgres were unreachable and the meter failed open. Treat used as a lower bound when present.
meta._test_modeOptional. true when the request was made with a vk_test_ key. The provider was not called.
tipOptional human-readable hint for the agent. null for most calls.

Errors

Errors share a common envelope and carry a structured fix hint where applicable. The category + retry_safe pair is the agent's branching signal:

{
  "status": "error",
  "error": {
    "code": "FIELD_TYPE_MISMATCH",
    "message": "SendGrid field 'loyalty_tier' is declared as Number but received string 'gold'.",
    "category": "user_input",
    "retry_safe": false,
    "provider": "sendgrid",
    "fix": {
      "action": "supply_matching_type",
      "hint": "Pass a numeric value or rename the property to avoid the existing field."
    }
  },
  "meta": {
    "request_id": "01JD5K9X7M0Q8V0QY4FYVQ3WZV",
    "total_latency_ms": 87
  }
}

Full code list with HTTP status and recovery action: Error reference →

Test mode

Calls made with a vk_test_… key:

Use test mode in CI and during agent-prompt development. Switch to vk_live_… when you're ready to actually create contacts.

Field mapping behavior

The first time the adapter sees an unknown property:

  1. Type is inferred — number → SendGrid Number, booleanNumber (0/1), ISO 8601 date string → Date, anything else → Text.
  2. Field is created via POST /v3/marketing/field_definitions.
  3. Mapping is recorded in field_mappings for the connection so subsequent calls hit a cache.
  4. Field name is added to data.fields_auto_created in the response.

SendGrid has a brief indexing lag (~750–3000 ms) after a new field is created. The adapter retries the contact PUT with backoff; you'll see slightly higher execution_time_ms on first-touch fields. Subsequent calls are instant.