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, mailchimp, or klaviyo. 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 | SendGrid and Mailchimp return md5(lowercase(trim(email))). Klaviyo returns the Klaviyo profile id. |
data.action | One of created, updated, upserted. Some providers cannot distinguish create from update in their upsert response. 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. Klaviyo always returns an empty array because properties do not require registration. |
meta.provider_metadata.import_job_id | SendGrid only. The underlying async import job id, surfaced for debugging after data.contact_id normalization. |
meta.job | Optional. Present when SendGrid accepts a contact import asynchronously. Treat status: "pending" as accepted provider work; immediate get_contact can return found: false until SendGrid finishes the import, so retry readback later before replaying. |
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 deterministic fixture envelope with a stable
data.contact_idandmeta.test_mode: true - Consume the separate test quota (
test_used/test_limit) without touching live usage
Use test mode in CI and during agent-prompt development. It has separate rate-limit and circuit-breaker buckets, so synthetic failures do not affect live provider traffic. 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 contact imports are asynchronous after the provider accepts the PUT. Vorlek returns the normalized contact id immediately with meta.job.status: "pending"; immediate get_contact can still return found: false until SendGrid finishes indexing the import. Retry get_contact later before replaying. Mailchimp custom fields are merge fields; Klaviyo does not require field registration.