Contacts
Manage workspace contacts and their notes. Contacts are the core record most other resources (deals, payments, bookings, campaigns) attach to.
All endpoints require authentication. There is no DELETE endpoint for contacts on the API.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/contacts |
List contacts (paginated, filterable) |
| GET | /api/v1/contacts/:id |
Get one contact |
| POST | /api/v1/contacts |
Upsert a contact by phone/email/national ID |
| PATCH | /api/v1/contacts/:id |
Update a contact by id |
| GET | /api/v1/contacts/:id/consent |
Read per-channel marketing consent — see Consent & Suppressions |
| PUT | /api/v1/contacts/:id/consent/:channel |
Record a consent decision — see Consent & Suppressions |
| GET | /api/v1/contacts/:id/documents |
List a contact's financial documents (requires the Payments feature) |
| GET | /api/v1/contacts/:id/payment-methods |
List a contact's saved cards — see saved cards and charges |
| POST | /api/v1/contacts/:id/charges |
Charge a saved card — see saved cards and charges |
| GET | /api/v1/contacts/:id/notes |
List a contact's notes |
| POST | /api/v1/contacts/:id/notes |
Add a note |
| PATCH | /api/v1/notes/:id |
Edit / pin a note |
| DELETE | /api/v1/notes/:id |
Delete a note |
Consent lives on its own page. A contact's WhatsApp/email marketing consent — the recorded decision, its legal basis, deliverability, and the composed send-time suppression verdict — is read and written through the consent endpoints, documented in Consent & Suppressions.
The contact object
Responses return the full contact record plus computed fields:
tags— array of tag ids (strings)groups— array of group idsevent_attendances—[{id, event_id, status, registered_at, attended_at, unregistered_at}]whatsapp_subscribed,email_subscribed(booleans),whatsapp_deliverability,email_deliverability— per-channel consent state (contacts without a subscription record reportfalse/"unknown")national_id— normalized national ID, ornull.owner_user_idandowner— the owning member id and{ id, name, email }, ornullwhen unowned.first_touchandlast_touch— the contact's first and most recent acquisition, each the same object shape as a row fromGET /api/v1/contacts/:id/acquisitions, ornullwhen the contact has no recorded touch. Present only on plans with Attribution — without it the two keys are absent entirely, which is how you tell "not entitled" from "no touches yet".score_band— read-only lead-scoring band:"cold","warm","hot", ornull
Round-trip warning — tags/groups are NAMES on input, IDS on output.
POST/PATCHaccept tag and group names;GETreturns ids. Never echo the ids from a GET back into a write: unrecognized names are auto-created, so a UUID sent as a "name" creates a brand-new tag literally named like that UUID. Map ids back to names first (see Tags & Contact Groups).
GET /api/v1/contacts
Standard list conventions apply (filter, sort, limit default 50 / cap 500, offset, search).
search matches name, first_name, last_name, email, phone, company_name (case-insensitive substring), plus an exact match on the E.164-normalized form of the term and the contact's historical phone/email identifiers.
curl -G "https://app.otok.io/api/v1/contacts" \
-H "Authorization: Bearer otok_live_abc123..." \
--data-urlencode 'filter={"lifecycle_stage":"customer"}' \
--data-urlencode 'sort=-updated_at' \
--data-urlencode 'limit=25'
Response 200 — { data, total, limit, offset }.
| Status | Meaning |
|---|---|
| 400 | Invalid filter JSON / non-object filter / invalid limit/offset / a mistyped filter value (Invalid filter value for "<field>": … — see filter-value validation) |
| 401 / 403 / 429 | Auth, plan, rate limit |
GET /api/v1/contacts/:id
| Param | Type | Notes |
|---|---|---|
id |
UUID (path) | Non-UUID → 400 |
Response 200 — a single contact object (same enrichment as list).
| Status | Meaning |
|---|---|
| 404 | "contacts with ID <id> not found" — unknown in this workspace |
POST /api/v1/contacts — upsert
Creates a contact, or updates the existing contact that the given phone/email resolves to. This is the recommended way to write contacts from external systems — including changing a contact’s phone or email.
Request body
Every field is optional. Unknown fields → 400.
| Field | Type | Constraints |
|---|---|---|
phone |
string | ≤32 chars; normalized to E.164 (local numbers use the workspace default country) |
name |
string | ≤200 |
first_name / last_name |
string | ≤100 each |
email |
string | valid email, ≤255 |
national_id |
string | ≤20 chars; Israeli national ID, normalized to nine digits after check-digit validation. Invalid check digits are ignored. Participates in matching after phone/email. |
owner_email |
string | Active workspace member’s login email; takes precedence over owner_user_id. An unknown or inactive member returns 400 INVALID_CONTACT_OWNER. |
owner_user_id |
UUID or null |
Active workspace member id; null clears the owner. |
avatar_url |
string | ≤500 |
notes |
string | ≤5000 |
lifecycle_stage |
enum | lead, prospect, customer, inactive, archived |
source |
enum | manual, import, widget, campaign, api, form |
block_state |
enum | none, workspace, global |
company_name |
string | ≤200 |
vat_number |
string | ≤40 |
job_title |
string | ≤120 |
industry |
string | ≤80 |
company_website |
string | ≤500 |
annual_revenue |
number | |
employee_count |
integer | 0 – 2,147,483,647 |
currency_preference |
string | ≤8 |
address_line1 / address_line2 |
string | ≤200 each |
city / state / country |
string | ≤100 each |
postal_code |
string | ≤20 |
gender |
enum | male, female, other, prefer_not_to_say |
date_of_birth |
string | ISO 8601 date |
language |
string | ≤12 |
utm_source / utm_medium / utm_campaign / utm_term / utm_content / gclid / fbclid |
string | ≤200 each |
msclkid / gbraid / wbraid / ttclid / li_fat_id |
string | ≤500 each; advertising click identifiers |
lead_score |
number | Engine-owned: silently ignored while workspace lead scoring is enabled — the response echoes the computed score. Writable only when scoring is disabled. |
linkedin_url / facebook_url |
string | ≤500 each |
instagram_handle / twitter_handle |
string | ≤100 each |
custom_fields |
object | Arbitrary keys; shallow-merged into the existing object on update |
tags |
string[] | Tag names, each 1–100 chars. Missing tags are auto-created. |
groups |
string[] | Group names, each 1–100 chars. Missing groups are auto-created. |
Changing a contact's owner also transfers eligible live records held by the previous owner, including open deals, tasks and inquiries, draft/sent quotes, and upcoming bookings.
An impossible incoming phone does not replace an existing valid phone. Send an empty string to explicitly clear it; inspect the returned phone after writing.
Marketing metadata
The following native fields appear in the contact's Marketing section. They
are optional strings accepted by both POST /api/v1/contacts and
PATCH /api/v1/contacts/:id, and returned with contact records (nullable when
unset). Supply them as top-level properties, alongside the UTM fields.
| Field | Maximum length | Description |
|---|---|---|
source_page_url |
2048 | URL of the source page associated with the contact. |
source_page_title |
500 | Title of the source page associated with the contact. |
referrer |
2048 | Referring URL or source associated with the contact. |
user_agent |
2048 | Browser or client user-agent string associated with the contact. |
affiliate |
200 | Affiliate name or identifier associated with the contact. |
Example request body:
{
"email": "jane@example.com",
"source_page_url": "https://example.com/signup",
"source_page_title": "Newsletter signup",
"referrer": "https://example.com/blog",
"user_agent": "Mozilla/5.0",
"affiliate": "partner-123"
}
Acquisition events and inquiry capture (POST only)
Use acquisition for an actual submission, including a returning contact submitting a new form. It is a request option, not a stored contact profile field.
Field inside acquisition |
Constraints |
|---|---|
event_id |
Required, non-empty string, ≤180 chars. Unique per workspace: reuse it on retries and the same touch and inquiry are returned, whichever contact the identifiers resolve to. Use a new id for each genuine submission — a fixed per-form id would collapse every submission into one. |
occurred_at |
ISO 8601 timestamp; omitted uses capture time. A future value is clamped to now. |
source |
What produced the touch on your side: form, widget, api, import or campaign. Informational — the touch is always recorded as having arrived through the API. |
visitor_id |
8–64 letters, digits, underscores or hyphens. Attaches the visitor's earlier anonymous visits to this contact. |
landing_url |
First page of the session. String, ≤2048 chars. |
conversion_url |
The page that submitted. String, ≤2048 chars. |
landing_referrer / referrer_url |
The referrer. landing_referrer is the preferred spelling; both are accepted. Strings, ≤2048 chars each. |
source_page_title |
String, ≤500 chars. |
utm_source / utm_medium / utm_campaign / utm_term / utm_content |
Strings, ≤500 chars each. |
gclid / fbclid / msclkid / gbraid / wbraid / ttclid / li_fat_id |
Strings, ≤500 chars each. |
platform_campaign_id / form_name |
Strings, ≤500 chars each. |
ip / user_agent |
The visitor's address and browser, as your system observed them. Never inferred from your API request — that context belongs to your server, not to the person. |
params |
Raw query parameters you want kept with the touch: at most 20 keys × 200 chars. Canonical keys (the UTM and click-id names above) are stripped, since they have their own fields. Stored for reading only — never exported, never sent on a webhook, and never usable as a contact field. |
attendance_id |
The event registration this submission produced, when you have one. Must name a registration in this workspace that belongs to this very contact, else 400 ATTENDANCE_NOT_FOUND / ATTENDANCE_CONTACT_MISMATCH. Registering through POST /api/v1/events/:id/attendances stamps it for you. |
The event's source values take precedence over corresponding top-level fields. Top-level marketing fields alone create acquisition context only when a contact is first created; ordinary profile updates do not add another acquisition event. Capture is best-effort: a successful profile write does not guarantee that attribution was recorded.
The top-level inquiry option controls the inquiry queue:
| Value | Behavior |
|---|---|
create (default) |
Opens an inquiry when a contact is created or an explicit acquisition is supplied, including for an existing contact. |
always |
Also captures ordinary updates. Without an acquisition id, updates for the same contact within one UTC hour collapse into one inquiry. |
never |
Opens no inquiry. Use for bulk backfills and CRM syncs. Does not disable an explicitly supplied acquisition event. |
With acquisition.event_id, both the touch and its inquiry are deduplicated on that id across the whole workspace — so a retry that resolves to a different contact (an identifier that turned out to belong to someone else) still records one touch, not two. Send the same event id when retrying.
Example body (all values are illustrative):
{
"email": "jane@example.com",
"acquisition": {
"event_id": "signup-example-001",
"landing_url": "https://example.com/signup",
"utm_source": "newsletter",
"utm_campaign": "autumn-course",
"form_name": "Course interest"
}
}
Both acquisition and inquiry are rejected on PATCH with 400. PATCH is a profile edit and never opens an inquiry.
Upsert resolution
phone,email, andnational_idare normalized.- The API looks up the current owner of each identifier — resolution is history-aware: a phone/email that was moved off a contact still resolves to its most recent holder if no current owner exists.
- Matching precedence is phone → email → national ID. Split matches follow the rules below. Always use the contact
idreturned by the API. - Match found → update: scalar fields overwrite,
custom_fieldsshallow-merge, andtags/groupsare added to the existing set (never removed by this route). - No match → create. Concurrent creates of the same identity are safe — the loser of the race is retried as an update of the winner.
The response is 201 in both cases (create and update) with the full contact object, plus a top-level boolean duplicate field: false when this request created the contact, true when it matched and updated an existing one.
Identity conflict — 409 CONTACT_MERGE_REQUIRED
When incoming identifiers resolve to different current contacts, POST can automatically merge complementary records: every non-empty phone, email and national ID must agree across the records and the incoming payload. Blanks can be filled. A phone-only record and a compatible email-only record can therefore become one contact; the response returns the surviving id with duplicate: true.
Conflicting identifiers, historical split matches, and pairs awaiting or preserving a human review decision still require in-app resolution. In that case the API opens a merge request and responds:
{
"statusCode": 409,
"error": "Conflict",
"error_code": "CONTACT_MERGE_REQUIRED",
"merge_request_id": "3a2b1c0d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"message": "The provided identifiers belong to different existing contacts. A merge request has been opened — resolve it in the app, then retry."
}
The non-identity fields of your request are parked with the merge request and applied when it is resolved. Retry after the merge request is resolved in the app. Repeated conflicting requests for the same pair reuse the same merge request.
Retrying after "Keep separate". When someone resolves the conflict in the app by keeping the contacts separate and choosing which one the submission belongs to, your retry of the same submission is applied to that chosen contact (201, duplicate: true) instead of opening another merge request. None of the submitted identifiers are moved onto it — the phone, email and national ID stay with the contacts that hold them — but the rest of the request (fields, tags, groups, acquisition, inquiry) lands on the chosen contact. If the identifiers point at contacts for which no such choice was made, the request still answers 409 CONTACT_MERGE_REQUIRED.
Example
curl -X POST "https://app.otok.io/api/v1/contacts" \
-H "Authorization: Bearer otok_live_abc123..." \
-H "Content-Type: application/json" \
-d '{
"phone": "+972501234567",
"email": "dana@example.com",
"first_name": "Dana",
"last_name": "Levi",
"lifecycle_stage": "lead",
"tags": ["VIP", "Newsletter"],
"custom_fields": { "plan_interest": "pro" }
}'
Response 201:
{
"id": "9c2f1a4e-3b7d-4e2a-9f0c-1d2e3f4a5b6c",
"phone": "+972501234567",
"email": "dana@example.com",
"name": "Dana Levi",
"first_name": "Dana",
"last_name": "Levi",
"lifecycle_stage": "lead",
"tags": ["b1a2c3d4-0000-0000-0000-000000000001", "b1a2c3d4-0000-0000-0000-000000000002"],
"groups": [],
"custom_fields": { "plan_interest": "pro" },
"whatsapp_subscribed": true,
"email_subscribed": false,
"score_band": null,
"duplicate": false,
"created_at": "2026-07-14T10:00:00.000Z",
"updated_at": "2026-07-14T10:00:00.000Z"
}
Errors
| Status | Code / message | Meaning |
|---|---|---|
| 400 | validation messages | Bad field values or unknown fields |
| 400 | error_code: "PHONE_BLACKLISTED" — "This phone number is on the blacklist and cannot be saved to a contact." |
The phone is blocked by a workspace or global blacklist rule |
| 400 | "Limit reached. Your plan allows a maximum of N contacts…" |
Contact limit reached (only applies when a cap is set on the workspace) |
| 409 | error_code: "CONTACT_MERGE_REQUIRED" |
See above |
Side effects
Creating or updating a contact via the API behaves like an in-app write: per-channel consent records are seeded (WhatsApp implied; email unknown when an email is provided, both attributed to source api), contact-change automations (tag/group/field triggers) fire, activity history is recorded, and connected integrations sync. block_state set to a non-none value without a blacklist match is recorded with an import block source.
PATCH /api/v1/contacts/:id
Update a contact by id. Same field set and validation as POST (all optional).
Semantics that differ from POST:
tags/groupsREPLACE the full set. The contact ends up with exactly the names you send; an empty array clears all tags/groups. (On POST they are additive.) Names still auto-create.custom_fieldsstill shallow-merge — you cannot remove a key by omitting it; set it tonullexplicitly.nameandfirst_name/last_namestay in sync: patching onlyfirst_namerecombines it with the storedlast_name; patching onlynamere-splits it on the first whitespace.block_statechanges route through the consent/blocking subsystem; lifting a block re-evaluates blacklist rules, and global blocks cannot be lifted via the API.
Identity conflict — 409 CONTACT_MERGE_REQUIRED (PATCH)
Setting phone/email/national_id to an identifier that belongs to a different contact does not apply the write. Like the POST upsert's conflict path, the API opens a merge request for the workspace to resolve in-app and responds:
{
"statusCode": 409,
"error": "Conflict",
"error_code": "CONTACT_MERGE_REQUIRED",
"merge_request_id": "3a2b1c0d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"message": "This change would give the contact a phone or email that already belongs to another contact. A merge request was opened — resolve it (merge or dismiss) to apply the change."
}
The non-identity fields sent in the same PATCH are held on the merge request and applied when it is resolved (merge or dismiss).
The conflict check is history-aware. A PATCH that sets a phone/email no contact currently holds but that another contact previously held also parks a merge request and returns this 409 — matching the in-app editor's behavior.
| Param | Type | Notes |
|---|---|---|
id |
UUID (path) | Non-UUID → 400 |
curl -X PATCH "https://app.otok.io/api/v1/contacts/9c2f1a4e-3b7d-4e2a-9f0c-1d2e3f4a5b6c" \
-H "Authorization: Bearer otok_live_abc123..." \
-H "Content-Type: application/json" \
-d '{ "lifecycle_stage": "customer", "tags": ["VIP"] }'
Response 200 — the updated contact object.
| Status | Code / message | Meaning |
|---|---|---|
| 400 | validation / unknown fields / non-UUID id | |
| 400 | error_code: "PHONE_BLACKLISTED" |
Only when the patch changes the phone to a blacklisted number |
| 404 | "Contact <id> not found" |
Unknown in this workspace |
| 409 | error_code: "CONTACT_MERGE_REQUIRED" |
The new phone/email belongs (or previously belonged) to another contact — see above |
| 409 | error_code: "CONTACT_ANONYMISED" |
The contact was anonymised (erased for privacy compliance in the app). Its profile can no longer be changed; only owner_user_id, tags and groups are still accepted |
GET /api/v1/contacts/:id/acquisitions
Read-only listing of one contact's acquisition touches — the per-submission record behind the in-app Journey, newest first. One row per submission, never a rollup: use it to answer "how did this person actually reach us, and how many times".
Plan feature required: this endpoint requires the Attribution feature on the workspace's plan. Without it, calls return
403witherror_code: "FEATURE_NOT_INCLUDED_IN_PLAN"— see feature-gated resource groups. The same gate hides thefirst_touch/last_touchblocks on the contact object.
| Param | Type | Notes |
|---|---|---|
id |
UUID (path) | Non-UUID → 400. A contact in another workspace → 404 |
kind |
string (query) | Narrow to one transport: web_visit, form_submission, ctwa_ad, email_click, campaign_reply, import, api, whatconverts_lead, lead_ad or legacy. Any other value → 400 |
limit |
integer (query) | Page size, default 50, max 200 |
offset |
integer (query) | Rows to skip, default 0 |
curl "https://app.otok.io/api/v1/contacts/9c2f1a4e-3b7d-4e2a-9f0c-1d2e3f4a5b6c/acquisitions?kind=api&limit=20" \
-H "Authorization: Bearer otok_live_abc123..."
Response 200 — { data, total, limit, offset }, ordered by occurred_at descending:
{
"data": [
{
"id": "b1d9c0f2-5a3e-4c18-9f77-2e6a8d4b0c31",
"event_id": "signup-example-001",
"kind": "api",
"source": "form",
"occurred_at": "2026-09-14T08:31:00.000Z",
"visitor_id": "v_8fa31c2b9d40",
"form_name": "Course interest",
"landing_url": "https://example.com/autumn",
"conversion_url": "https://example.com/autumn/signup",
"landing_referrer": "https://www.google.com/",
"referrer_url": "https://www.google.com/",
"source_page_title": "Autumn course",
"utm_source": "newsletter",
"utm_medium": "email",
"utm_campaign": "autumn-course",
"utm_term": null,
"utm_content": null,
"gclid": null,
"gbraid": null,
"wbraid": null,
"fbclid": null,
"msclkid": null,
"ttclid": null,
"li_fat_id": null,
"ctwa_clid": null,
"platform_campaign_id": null,
"params": { "plan": "pro" },
"user_agent": "Mozilla/5.0 …",
"ip": "203.0.113.9",
"attendance_id": null,
"form_id": null,
"landing_page_id": null,
"campaign_id": null,
"email_campaign_id": null,
"created_at": "2026-09-14T08:31:00.412Z"
}
],
"total": 3,
"limit": 20,
"offset": 0
}
Reading a row.
kindis the transport oToK observed (apifor anything sent through this API).sourceis what the caller declared produced it. They answer different questions and are never mapped onto one another.landing_urlis the first page of the session;conversion_urlis the page that submitted. Embedded forms currently report the submitting page for both.paramsholds the raw query parameters the producer kept, minus the canonical UTM and click-id keys. It is readable here and nowhere else — not in exports, not on webhooks.ipanduser_agentare the visitor's, exactly as the producer stated them, and are kept for the life of the touch.event_idis the producer's own submission id (workspace-unique for API touches).attendance_idlinks the touch to the event registration it produced, when there is one.- The row carries no channel classification: that lives on the contact's
first_touch_channel/last_touch_channelrollups.
GET /api/v1/contacts/:id/documents
Read-only listing of a contact's financial documents — invoices, receipts, and credit documents — aggregated from every stored document pointer on the contact's payments, payment entries, and payment requests, deduplicated, merged, and sorted date-descending (nulls last).
Plan feature required: unlike the rest of the contacts routes, this endpoint requires the Payments feature on the workspace's plan (the same gate as
/v1/payments*). Without it, calls return403witherror_code: "FEATURE_NOT_INCLUDED_IN_PLAN"— see feature-gated resource groups.
| Param | Type | Notes |
|---|---|---|
id |
UUID (path) | Non-UUID → 400 |
live |
true | false (query) |
Default false (stored pointers only). true additionally queries the connected payment provider for a live document listing and merges it in — bounded to ~2.5 s; a timeout or provider failure degrades to the stored listing (reported in live.error), and a missing/not-entitled provider degrades to an empty live listing. Any other value → 400 "Invalid live: must be true or false" |
curl "https://app.otok.io/api/v1/contacts/9c2f1a4e-3b7d-4e2a-9f0c-1d2e3f4a5b6c/documents?live=true" \
-H "Authorization: Bearer otok_live_abc123..."
Response 200:
{
"documents": [
{
"key": "sumit:123456",
"kind": "tax_invoice_receipt",
"rawType": null,
"isCredit": false,
"provider": "sumit",
"documentId": "123456",
"number": "2043",
"url": "https://app.sumit.co.il/…/document.pdf",
"date": "2026-07-14T10:00:00.000Z",
"amount": 350,
"currency": "ILS",
"origin": "merged",
"sources": [
{ "type": "contact_payment", "id": "7b6a5c4d-…" },
{ "type": "provider", "provider": "sumit" }
]
}
],
"live": { "attempted": true, "ok": true, "complete": true, "error": null }
}
Document fields:
| Field | Meaning |
|---|---|
key |
Aggregator-computed stable render key — carries no semantics |
kind |
Canonical document kind (tax_invoice, tax_invoice_receipt, receipt, receipt_for_invoice, proforma_invoice, donation_receipt, credit_invoice, credit_invoice_receipt, credit_receipt, credit_donation_receipt, order, price_quote, delivery_note, payment_demand) — or null with the provider's original label in rawType |
isCredit |
Credit/refund document |
provider |
cardcom / sumit / null |
documentId / number |
Provider durable id; human-facing document number |
url |
May be null (legacy number-only rows) — always check before opening, and only open http(s) URLs |
date / amount / currency |
Stored documents carry the host row's instant/amount; live documents carry the provider document's own values |
origin |
stored, live, or merged (found in both) |
sources |
The records the document was aggregated from: {type: "contact_payment", id}, {type: "payment_entry", id, paymentId}, {type: "payment_request", id}, {type: "provider", provider} |
The live object reports the provider lookup: attempted (a live lookup ran), ok (false = it failed or timed out), complete (false = the live listing may be missing documents), error ("timeout", "provider_error", or null). With the default live=false, attempted is false and the listing is stored-only.
| Status | Meaning |
|---|---|
| 400 | Non-UUID contact id, or a malformed live value |
| 403 | FEATURE_NOT_INCLUDED_IN_PLAN — plan lacks the Payments feature |
| 404 | "contacts with ID <id> not found" — same lookup and wording as GET /v1/contacts/:id, so an unknown or cross-workspace contact answers identically instead of an empty-but-plausible list |
Saved cards and charges
A contact's saved cards are cards the payer agreed to keep on file — at checkout on a pay-link, or entered in the app — stored at the workspace's connected payment provider (Cardcom / Sumit). Through the API you can list them (masked) and charge one directly, without sending a pay-link. Card numbers never pass through the API: a stored card is charged by reference.
Plan feature required: both routes require the Workspace payments feature (
workspace_payments) — the payment requests gate, not thepaymentsledger gate — and answer403 FEATURE_NOT_INCLUDED_IN_PLANwithout it.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/contacts/:id/payment-methods |
List a contact's saved cards (masked) |
| POST | /api/v1/contacts/:id/charges |
Charge a saved card — requires saved-card charge access on the API key |
GET /api/v1/contacts/:id/payment-methods
curl "https://app.otok.io/api/v1/contacts/9c2f1a4e-3b7d-4e2a-9f0c-1d2e3f4a5b6c/payment-methods" \
-H "Authorization: Bearer otok_live_abc123..."
Response 200 — { payment_methods }, newest first:
{
"payment_methods": [
{
"id": "5d6e7f80-9a1b-4c2d-8e3f-405162738495",
"provider": "cardcom",
"brand": "visa",
"last4": "4242",
"expiryMonth": 12,
"expiryYear": 2028,
"tokenExpiresAt": null,
"status": "active",
"isDefault": true,
"consentSource": "checkout_optin",
"consentedAt": "2026-07-14T10:00:00.000Z",
"lastUsedAt": "2026-08-01T08:00:00.000Z",
"createdAt": "2026-07-14T10:00:00.000Z"
}
]
}
Unlike most API objects, saved-card fields are camelCase.
| Field | Meaning |
|---|---|
id |
The saved card's id — pass it as method_id to charge this card |
provider |
cardcom / sumit — only cards saved with the currently connected provider can be charged |
brand / last4 / expiryMonth / expiryYear |
Display facts as the provider reported them (any may be null) |
tokenExpiresAt |
When the provider will delete the stored card, if it declared one; else null |
status |
active, revoked or expired — only active cards can be charged |
isDefault |
The contact's default card (at most one) |
consentSource / consentedAt |
How and when the payer agreed to keep the card on file (e.g. checkout_optin, payer_link, manual, in_person) |
lastUsedAt / createdAt |
ISO 8601 |
| Status | Meaning |
|---|---|
| 400 | Non-UUID contact id |
| 403 | FEATURE_NOT_INCLUDED_IN_PLAN |
| 404 | "contacts with ID <id> not found" — unknown or another workspace's contact |
POST /api/v1/contacts/:id/charges
Charges one of the contact's saved cards through the connected payment provider — the same charge as the in-app Charge saved card button. The result is a payment request with charge_kind: "token" (no pay-link), recorded on the contact's payments ledger once paid, with the tax document auto-issued when configured.
Saved-card charge access (API key capability). This route requires the calling API key to carry saved-card charge access (
allow_charges). It is off by default on every key; only the workspace owner can grant it, in Settings → Developers → API keys. A key without it gets403witherror_code: "API_KEY_CHARGES_DISABLED"— checked before anything else, including the contact lookup.
Request body
| Field | Type | Required | Constraints |
|---|---|---|---|
idempotency_key |
string | yes | 8–124 chars (trimmed). See idempotency |
amount |
number | yes | 0.01 – 9,999,999,999.99, ≤2 decimals — major units |
method_id |
UUID | no | The saved card to charge (from payment-methods). Omitted → chosen by method_selection |
method_selection |
enum | no | When no method_id is sent: default (the contact's default card — the default) or most_recent (the most recently charged card, else the newest; expired cards are skipped) |
currency |
enum | no | ILS, USD, EUR, GBP. Omitted → the workspace payment currency |
title |
string | no | ≤200 — charge title |
product_id |
UUID | no | Catalog product — the title then derives from the product name |
note |
string | no | ≤2000 — internal note |
document_kind |
enum | no | Tax-document kind — the payment-request vocabulary. Omitted → the provider/account default |
auto_issue_document |
boolean | no | Auto-issue the tax document on a successful charge |
installments |
integer | no | 1–36 — card installments to charge with (omitted = none) |
test_mode |
boolean | no | Authorise-only test run — 400 TOKEN_TEST_MODE_NOT_SUPPORTED when the provider has no test mode |
deal_id |
UUID | no | Deal to bind the charge to |
vat_mode / vat_rate |
enum / number | no | Per-charge VAT override, always together (inclusive/exclusive, 0–100). Omitted → the product's pair, else the workspace default |
sale_id |
UUID | no | Collect for an existing sale of this contact — allocated when the charge settles. Same checks as on payment requests |
terminal_number |
integer | no | Cardcom only — charge on this configured terminal. Omitted → the default terminal; unknown number or a provider without terminals → 400 |
Card fields of any kind are refused (unknown properties are rejected).
curl -X POST "https://app.otok.io/api/v1/contacts/9c2f1a4e-3b7d-4e2a-9f0c-1d2e3f4a5b6c/charges" \
-H "Authorization: Bearer otok_live_abc123..." \
-H "Content-Type: application/json" \
-d '{
"idempotency_key": "plan-renewal-2026-08",
"amount": 250,
"title": "August membership",
"method_selection": "default"
}'
Outcomes
| Status | Body | Meaning |
|---|---|---|
200 |
{ outcome: "paid", duplicate, payment_request } |
Approved and verified. payment_request is the settled request (status: "paid", contact_payment_id set) |
202 |
{ outcome: "processing", duplicate, payment_request_id } |
The final outcome is not known yet (the provider's answer was late or unclear). It settles on its own — poll GET /v1/payment-requests/:id. Never retry it with a new key |
409 |
error_code: "TOKEN_CHARGE_DECLINED", failure_reason, payment_request_id |
The card was declined. Try again with a new key (or another card) |
Charge idempotency
idempotency_key is required. Keys are scoped to the API key that sent them: a replay of the same key answers the original charge's outcome with duplicate: true and never charges twice — a paid charge replays 200, one still processing replays 202. Reusing a key with a different contact, amount or currency answers 409 IDEMPOTENCY_KEY_MISMATCH and charges nothing. A new charge attempt — for example after a decline — needs a new key.
Errors
| Status | Code / message | Meaning |
|---|---|---|
| 400 | validation array | Missing/short idempotency_key, bad amount, unknown fields (including any card data) |
| 400 | NO_PAYMENT_PROVIDER |
No payment provider is connected |
| 400 | TOKEN_TEST_MODE_NOT_SUPPORTED |
test_mode with a provider that has no test mode |
| 400 | "terminal_number <n> matches no configured Cardcom terminal" |
Unknown terminal |
| 400 | INVALID_PRODUCT / PRODUCT_INACTIVE / CURRENCY_MISMATCH |
Product or sale checks |
| 403 | FEATURE_NOT_INCLUDED_IN_PLAN |
Plan lacks workspace_payments |
| 403 | API_KEY_CHARGES_DISABLED |
The API key lacks saved-card charge access |
| 404 | "contacts with ID <id> not found" |
Unknown contact |
| 409 | TOKEN_CHARGE_DECLINED |
The card was declined (see outcomes) |
| 409 | IDEMPOTENCY_KEY_MISMATCH |
The key was already used for a different charge |
| 409 | NO_CHARGEABLE_METHOD |
No active, unexpired saved card for the connected provider (or method_id is not one) |
| 409 | TOKEN_CHARGE_NOT_SUPPORTED |
The connected provider does not support saved-card charges |
| 409 | TERMINAL_NOT_CHARGEABLE |
The chosen Cardcom terminal cannot charge |
| 409 | CONTACT_ANONYMISED |
The contact was anonymised — no new charges |
| 409 | PLAN_FEATURE_REQUIRED / SALE_CONTACT_MISMATCH / SALE_ALREADY_CANCELLED |
sale_id checks |
| 502 | TOKEN_CHARGE_FAILED |
The provider request failed — nothing was charged; retry with a new key |
A charge fires no payment_request.* webhooks (direct saved-card charges never do); a paid charge appears on the payments ledger and fires the payment-recorded automations like any other payment.
Notes
Notes are plain-text annotations on a contact. API note payloads are text only (rich-text and mentions are in-app features; sending them returns 400). Notes created via the API have no author user and are attributed to source api.
Note object:
{
"id": "5e4d3c2b-...",
"workspace_id": "...",
"contact_id": "9c2f1a4e-...",
"author_user_id": null,
"author_name": null,
"source": "api",
"body": "Asked for a demo next week",
"body_json": null,
"mentioned_user_ids": null,
"pinned_at": null,
"conversation_id": null,
"created_at": "2026-07-14T10:05:00.000Z",
"updated_at": "2026-07-14T10:05:00.000Z"
}
(author_name is included on list responses only.)
GET /api/v1/contacts/:id/notes
Returns a JSON array of all the contact's notes — this endpoint is not paginated and takes no query parameters. Pinned notes come first (most recently pinned on top), then the rest newest-first.
| Status | Meaning |
|---|---|
| 400 | Non-UUID contact id |
| 404 | "Contact not found" |
POST /api/v1/contacts/:id/notes
| Field | Type | Required | Constraints |
|---|---|---|---|
body |
string | yes | ≤5000 chars; trimmed — empty after trim → 400 "Note body cannot be empty" |
pinned |
boolean | no | true pins the note immediately |
curl -X POST "https://app.otok.io/api/v1/contacts/9c2f1a4e-3b7d-4e2a-9f0c-1d2e3f4a5b6c/notes" \
-H "Authorization: Bearer otok_live_abc123..." \
-H "Content-Type: application/json" \
-d '{ "body": "Asked for a demo next week", "pinned": true }'
Response 201 — the created note.
| Status | Meaning |
|---|---|
| 400 | Empty/oversized body, unknown fields |
| 404 | "Contact not found" |
PATCH /api/v1/notes/:id
| Field | Type | Required | Constraints |
|---|---|---|---|
body |
string | no | ≤5000; empty → 400 |
pinned |
boolean | no | Pin/unpin |
Both fields are optional; sending neither returns the current note unchanged. A body change bumps updated_at (shows as "edited" in-app); a pin toggle alone does not. If the note is the contact's profile note, a body edit also updates the contact's notes field (and fires the corresponding field-change automations).
Response 200 — the note after the update.
| Status | Meaning |
|---|---|
| 400 | Empty body / >5000 chars / non-UUID id |
| 404 | "Note not found" or "Contact not found" |
DELETE /api/v1/notes/:id
Response 200:
{ "success": true }
Deleting the contact's profile note also clears the contact's notes field. A deletion breadcrumb is kept in the contact's activity timeline.
| Status | Meaning |
|---|---|
| 404 | "Note not found" or "Contact not found" |