oToK Developer Docs

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:

Round-trip warning — tags/groups are NAMES on input, IDS on output. POST/PATCH accept tag and group names; GET returns 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

  1. phone, email, and national_id are normalized.
  2. 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.
  3. Matching precedence is phone → email → national ID. Split matches follow the rules below. Always use the contact id returned by the API.
  4. Match found → update: scalar fields overwrite, custom_fields shallow-merge, and tags/groups are added to the existing set (never removed by this route).
  5. 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:

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 403 with error_code: "FEATURE_NOT_INCLUDED_IN_PLAN" — see feature-gated resource groups. The same gate hides the first_touch / last_touch blocks 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.


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 return 403 with error_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 the payments ledger gate — and answer 403 FEATURE_NOT_INCLUDED_IN_PLAN without 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 gets 403 with error_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"