C2C Public API (v1)
Overview
The C2C (tenant public) API is a versioned, JSON API scoped per tenant. All routes under /api/c2c share the same authentication, API version validation, and request logging.
Base URL pattern:
https://{your-tenant-host}/api/c2cExample: https://mytenant.click2close.io/api/c2c
Full endpoint URLs are base URL + path, e.g. POST https://{tenant-host}/api/c2c/contacts/assets.
Required headers (all C2C endpoints)
Header | Required | Description |
|---|---|---|
| Yes | Must be |
API key | Yes | See Authentication. Missing or invalid → 401 |
| Yes (for JSON bodies) | Must be |
Authentication
Provide exactly one of:
Authorization: Bearer c2c_{secret}— full key including thec2c_prefix.X-Api-Key: c2c_{secret}— same string as the Bearer token.
API keys are tenant-scoped and issued in the product. The plaintext key must start with c2c_.
Available endpoints
Method | Path | Description |
|---|---|---|
|
| Health check for the C2C stack on this tenant. |
|
| Attach one or more base64-encoded files to an existing contact (identified by email). |
| /api/c2c/coaching/sessions | Report aggregate coaching session progress for an existing client (identified by email) |
GET /api/c2c/health
Success: HTTP 200
{
"data": {
"status": "ok"
}
}
Use this to verify DNS, TLS, X-Api-Version, and API key before heavier integrations.
POST /api/c2c/contacts/assets
Uploads files as base64, decodes them on the server, and stores them as ** Media Library** assets on the Contact.
Purpose
Target contact must already exist (matched by email).
Attach 1–10 files per request; each file must pass MIME allowlist, size limits, and content-vs-declared-MIME checks.
Request
Headers
X-Api-Version: v1Content-Type: application/jsonAuthorization: Bearer c2c_…orX-Api-Key: c2c_…
Body (JSON)
Field | Type | Required | Description |
|---|---|---|---|
|
| Yes | Contact email. Lookup is case-insensitive ( |
|
| Yes | Min 1, max 10 objects. |
Each element of files
Field | Type | Required | Description |
|---|---|---|---|
|
| Yes | Max 255 characters. Logical file name (basename used if a path is given). |
|
| Yes | Must be one of the allowed MIME types listed below. Compared to detected MIME from decoded bytes ( |
|
| Yes | File bytes base64-encoded. Optional data URI prefix supported: |
|
| No | Max 255. Optional accessibility text stored on the media record. |
|
| No | Max 1000. Optional description stored on the media record. |
Allowed mime_type values
Images:
image/jpeg,image/png,image/webp,image/gif,image/svg+xmlPDF:
application/pdfSpreadsheets / CSV:
text/csv,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheetPlain text:
text/plain
Size limits
Limit | Value |
|---|---|
Per file (decoded binary) | ≤ 25 MB |
Total across all | ≤ 50 MB |
MIME aliases
The server may accept a declared mime_type if detected MIME matches known aliases (for example: XLS/XLSX sometimes detected as application/octet-stream; CSV as text/plain). Exact equality between declared and detected MIME also passes.
Processing flow
Validate JSON and custom rules (base64, sizes, MIME allowlist, MIME vs content).
Resolve Contact with
LOWER(email) = lower(trim(email)). If none → 422contact_not_found.For each file in array order:
Decode base64 → write temp file.
Raster images (not SVG) whose original extension is not
gifare converted to WebP before save (aligned with internal media upload behavior). GIF and SVG are not converted through that path.Attach media to contact
defaultcollection,publicon tenant disk.
Return 201 with contact + created asset metadata.
Unexpected server errors during upload → 500
store_failed(logged server-side).
Success response
HTTP 201 Created
{
"data": {
"contact": {
"uuid": "<contact_uuid>",
"email": "<email_as_stored>"
},
"assets": [
{
"uuid": "<media_uuid>",
"name": "<stored_name>",
"mime_type": "<mime_type>",
"type": "image|file",
"size": 12345,
"created_at": "YYYY-mm-dd HH:ii:ss"
}
]
}
}
type:imageif stored MIME is underimage/*, otherwisefile.name: May differ from requestfilename(e.g. WebP conversion changes extension for eligible images).
Error responses
Standard envelope:
{
"error": "<code>",
"message": "<message>"
}
Validation (422):
{
"error": "validation_failed",
"message": "<message>",
"errors": {}
}
HTTP |
| Typical cause |
|---|---|---|
400 |
|
|
400 |
| Version other than |
401 |
| Missing/invalid key or wrong prefix |
422 |
| Schema, base64, size, MIME allowlist, or MIME/content mismatch |
422 |
| No contact for given email |
500 |
| Unexpected error while storing media |
User-visible message values may be localized (en / es) depending on server configuration (see resources/lang/*/api_error.php, key c2c_contact_asset).
Example (curl)
curl --request POST \
--url 'https://{tenant-host}/api/c2c/contacts/assets' \
--header 'Authorization: Bearer c2c_YOUR_KEY' \
--header 'Content-Type: application/json' \
--header 'X-Api-Version: v1' \
--data '{
"email": "user@example.com",
"files": [
{
"filename": "document.pdf",
"mime_type": "application/pdf",
"content": "<base64_here>"
}
]
}'
POST /api/c2c/coaching/sessions
Records the session counters an external coaching platform tracks for a client, and stores them against that client's coaching order.
Purpose
Target contact must already exist (matched by email) and must have at least one coaching order.
A coaching package is a product of type digital under the health-coaching shop category. Products of any other type or category are not coaching packages.
The payload is an aggregate per client, not a list of individual sessions. One rollup is stored per coaching order and is overwritten on every call.
Request
Headers
Header | Value |
|---|---|
|
|
|
|
|
|
Body (JSON)
Field | Type | Required | Description |
|---|---|---|---|
| string | Yes | Contact email. Lookup is case-insensitive (trim + lowercase comparison). Contact must exist and not be soft-deleted. |
| integer | Yes | Minimum 0. Must equal the sessions included in the purchased package when a quota is configured — see Session quota. Otherwise → |
| integer | Yes | Minimum 0. Must be less than or equal to |
| integer | No | Informational. The server always recomputes |
| string | No | Date of the most recent completed session, format |
Session quota
How many sessions each coaching package includes is configured per package inside the product, in the Coaching dashboard under Session packages (for example 90 Day → 6, 6-Month → 12, 12-Month → 24).
The expected quota for a request is the sum of sessions_included × quantity across every coaching item on the matched order. The product is the authority: a sessions_purchased that disagrees is rejected rather than silently overwriting the configured value.
If no coaching item on the matched order has a quota configured yet, the check is skipped and sessions_purchased is accepted as sent. This is what allows an integration to start posting before the packages have been configured.
Processing flow
Validate the JSON schema (types, minimums,
sessions_completed ≤ sessions_purchased, date format).Resolve Contact with
LOWER(email) = lower(trim(client_email)). If none →422 contact_not_found.Find the contact's orders containing at least one coaching package, newest first. If none →
422 coaching_order_not_found. The rollup is bound to the newest one; the number of coaching orders the email resolved to is recorded on the record for traceability. Multiple matches never reject the payload.Compute the expected quota for that order. If a quota is configured and it differs from
sessions_purchased→422 sessions_mismatch; nothing is written.Recompute
sessions_remaining. A value reported by the caller that differs is stored as a discrepancy alongside the record.Insert or update the rollup for that order (counters,
last_session_date, received timestamp, metadata).Return
200with the stored rollup.
Unexpected server errors → 500 store_failed (logged server-side).
Success response
HTTP 200 OK
json
{
"data": {
"client": {
"uuid": "<contact_uuid>",
"email": "<email_as_stored>"
},
"sessions_purchased": 6,
"sessions_completed": 2,
"sessions_remaining": 4,
"last_session_date": "2026-05-08",
"status": "Active"
}
}sessions_remaining: always the value stored by the server, which may differ from the one sent.last_session_date:YYYY-MM-DD, ornullif never reported.status:Completedwhensessions_purchased > 0andsessions_remaining = 0; otherwiseActive.
Error responses
Standard envelope, as with every C2C endpoint:
json
{
"error": "<code>",
"message": "<message>"
}sessions_mismatch extends the envelope with the two values that disagree, so the caller can correct without a support round trip:
json
{
"error": "sessions_mismatch",
"message": "<message>",
"expected": 6,
"received": 12
}HTTP | error | Typical cause |
|---|---|---|
400 |
|
|
400 |
| Version other than |
401 |
| Missing/invalid key or wrong prefix |
422 |
| Schema, missing field, negative value, |
422 |
| No contact for the given email |
422 |
| The contact exists but has never bought a coaching package |
422 |
|
|
500 |
| Unexpected error while storing progress |
User-visible message values may be localized (en / es) depending on server configuration (see resources/lang/*/api_error.php, key c2c_coaching_sessions).
Example (curl)
curl --request POST \
--url 'https://{tenant-host}/api/c2c/coaching/sessions' \
--header 'Authorization: Bearer c2c_YOUR_KEY' \
--header 'Content-Type: application/json' \
--header 'X-Api-Version: v1' \
--data '{
"client_email": "jane@email.com",
"sessions_purchased": 6,
"sessions_completed": 2,
"sessions_remaining": 4,
"last_session_date": "2026-05-08"
}'Integrator notes
The API does not create contacts; ensure the contact exists first.