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
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | yes | RFC 5321 email format. Trimmed + lowercased before dispatch. Max 320 chars. |
first_name | string | no | Max 255 chars. |
last_name | string | no | Max 255 chars. |
phone | string | no | Max 64 chars. Sent as phone_number to SendGrid. |
properties | object | no | Free-form custom fields. Values must be string, number, or boolean. Max 50 keys per call. Auto-created at the provider on first use. |
provider | string | no | One 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
}
| Field | Notes |
|---|---|
data.contact_id | Provider-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.action | One 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_created | Custom-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_id | ULID. Echo back in support requests. |
meta.quota.check_skipped | Optional. true only when both Redis and Postgres were unreachable and the meter failed open. Treat used as a lower bound when present. |
meta._test_mode | Optional. true when the request was made with a vk_test_ key. The provider was not called. |
tip | Optional 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:
- Validate input (Zod schema, scope checks)
- Skip credential decrypt + provider dispatch
- Return a mocked envelope with
data.contact_id: "test_…"andmeta._test_mode: true - Do not consume quota
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:
- Type is inferred —
number→ SendGridNumber,boolean→Number(0/1), ISO 8601 date string →Date, anything else →Text. - Field is created via
POST /v3/marketing/field_definitions. - Mapping is recorded in
field_mappingsfor the connection so subsequent calls hit a cache. - Field name is added to
data.fields_auto_createdin 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.