API
Contacts API
Create and update contacts from any system — a backend, a data warehouse, a partner integration — through one generic endpoint, single or batch. Field names are matched intelligently, unknown fields become custom properties, and consent is protected by design: syncing data can never resubscribe someone who opted out.
Which endpoint do I want?
- POST /api/v1/contacts (this guide) — syncing contact data: bulk upserts, enrichment, migrations. Never triggers automations.
- POST /api/leads/intake — a signup event: one person just opted in, welcome automations should fire.
1. Create an API key
- Go to Store settings → API keys and create a key. The narrow Contact intake only scope is enough for this endpoint; the optional
crmblock below needs a Full access key. - Authenticate every call with
Authorization: Bearer sc_live_…(orX-API-Key).
2. One contact
curl -X POST https://www.sendcore.me/api/v1/contacts \
-H "Authorization: Bearer sc_live_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"email": "anna@example.com",
"first_name": "Anna",
"phone": "+39 333 1234567",
"birthday": "1990-07-15",
"tags": ["VIP", "Fiera Milano"],
"consent": "subscribed",
"consent_source": "landing_signup",
"piano_scelto": "premium"
}'Response — one row per contact, always telling the whole truth:
{
"ok": true,
"contact": {
"email": "anna@example.com",
"created": true, // created vs updated
"consent_state": "SUBSCRIBED",
"consent_preserved": false, // true = your "subscribed" did not apply
"first_subscription": true, // transitioned into SUBSCRIBED
"suppressed": false // true = on the unsubscribe list: accepted, but won't be mailed
}
}3. Batch
Send up to 500 contacts per request as { "contacts": [ … ] }. Rows that fail validation are reported by index in invalid without failing the rest; duplicate emails within one batch collapse to the last occurrence.
curl -X POST https://www.sendcore.me/api/v1/contacts \
-H "Authorization: Bearer sc_live_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"contacts": [
{"email": "anna@example.com", "first_name": "Anna"},
{"email": "marco@example.com", "first_name": "Marco", "consent": "subscribed"}
]}'Response:
{
"ok": true,
"results": [ { "email": "…", "created": true, … } ],
"invalid": [ { "index": 3, "error": "invalid_email" } ],
"stats": { "received": 120, "created": 80, "updated": 39, "invalid": 1, "suppressed": 2, "consent_preserved": 0 }
}Field names
Keys go through the same matching engine as the CSV importer: email_address, firstName, phone_number, Date of birth, a single full_name (split into first/last) — including Italian names like telefono or cognome — all land on the right field. city, country and state are stored as canonical custom properties. Any other scalar key is kept as a custom property (at most 15 per contact); non-scalar values are refused and listed in ignored_keys rather than guessed at. Explicit custom_properties win over auto-detected keys.
Consent, precisely
- Omit consent when syncing data. The presence of an email in your system is not marketing consent. A row without
consentnever changes an existing contact's state; a new contact lands asUNKNOWN. "consent": "subscribed"is an assertion that this person actually opted in (form, checkbox, verbal consent you recorded). Send it only then — it updates existing contacts too.- Mirroring another system? A full export or a nightly sync carries the consent you recorded back then, not what the person chose since. Add
"consent_policy": "preserve"at the top level of the request (next tocontacts, or beside the fields of a single contact):subscribedthen applies to new contacts only, an existing contact keeps the choice made in Sendcore, and every other field still updates. Rows where that protection applied come back withconsent_preserved: true. The default,"assert", is the behaviour above. "consent": "unsubscribed"always applies: downgrading is the safe direction. Accepted dialects: yes/no, true/false, subscribed/unsubscribed/non-subscribed, opted in/opted out…- People who clicked unsubscribe are on the suppression list, and this API never removes them from it, whatever you send. Their rows come back with
suppressed: trueand are never markedSUBSCRIBED: data accepted, no marketing email will go out.
A nightly mirror of your own database, then, looks like this — new people land subscribed, everyone else keeps their choice, and every address, phone and property still updates:
{
"consent_policy": "preserve",
"contacts": [
{ "email": "anna@example.com", "first_name": "Anna", "city": "Firenze", "consent": "subscribed" },
{ "email": "marco@example.com", "first_name": "Marco", "city": "Prato", "consent": "subscribed" }
]
}CRM handoff (optional)
Add a crm block to any row to also create or update a CRM card, idempotently: same (source, external_id) → same card, forever (external_id defaults to the contact's email). Needs a Full access key and a plan with the CRM; if either is missing the contact is still saved and the row's crm result carries the error.
{
"email": "anna@example.com",
"consent": "subscribed",
"crm": {
"pipeline_id": "…", // required — see /api/v1/crm/pipelines
"stage_id": "…", // optional: moves the card when it changes
"external_id": "LEAD-12345", // optional: defaults to the email
"source": "my_backend", // optional label, default contacts_api
"title": "Anna — premium", // optional
"amount": 1200 // optional
}
}Idempotency, limits, errors
- Repeating a request converges to the same state — safe to retry on any failure.
- On
429or a 5xx, retry with exponential backoff (2s, 4s, 8s), as the PHP example does. Other 4xx are validation refusals: fix the payload or the key instead of retrying. - Rate limits: 240 requests/min per IP, 300/min per account (HTTP 429,
rate_limit_exceeded). - Errors are stable tokens with the usual statuses:
missing_api_key/invalid_api_key(401),key_scope_insufficient(403),invalid_json/invalid_email/batch_too_large(400).