oToK Developer Docs

Getting Started

The oToK REST API lets you manage contacts, tags, groups, campaigns, deals, payments, orders, bookings, broadcast email campaigns, newsletters, and transactional email from your own systems. All endpoints live under a versioned /v1 path and authenticate with workspace API keys.

Base URL

https://app.otok.io/api/v1/

Interactive API documentation (Swagger UI) is served at:

https://app.otok.io/api/v1/docs

Creating an API key

  1. In the oToK app, open Settings → Developers → API keys.
  2. Click Create, give the key a name (up to 100 characters), and confirm.
  3. Copy the key from the one-time reveal dialog. Keys look like otok_live_… and are shown exactly once — oToK stores only a hash, so a lost key cannot be recovered. Create a new one instead.

Notes:

Plan requirement

The REST API requires a plan with API access (Growth or higher). Requests from a workspace whose plan lacks API access are rejected with:

HTTP 403
{"statusCode": 403, "message": "The REST API requires a plan with API access (Growth or higher)", "error": "Forbidden"}

Feature-gated resource groups

Endpoint groups that mirror a plan-gated product area additionally require that feature on the workspace's plan. Every route in each group is gated — reads and writes alike:

Endpoints Required plan feature
/v1/deals* (all routes) and /v1/pipelines Deals
/v1/payments* (all routes, including /refund) and GET /v1/contacts/:id/documents Payments (payments)
/v1/payment-requests* (all routes) Workspace payments (workspace_payments)
/v1/orders* (all routes, including the action routes) Orders
/v1/campaigns* (all routes, including /execute) Campaigns
/v1/bookings* and /v1/meeting-types* (all routes) Booking
/v1/email-campaigns* and /v1/suppressions* (all routes) Email marketing (email_marketing)
/v1/newsletters* and /v1/newsletter-issues* (all routes) Newsletters (newsletters)

Note the two distinct payment gates: payments covers the payments ledger (/v1/payments*) and the contact documents read, while workspace_payments covers pay-links collected through the workspace's own connected payment provider (/v1/payment-requests*). A workspace can hold either feature without the other — the 403 message embeds whichever feature id is missing.

A workspace whose plan lacks the feature receives 403 Forbidden on every call in the group, with this body (note: unlike the standard shape, there is no statusCode field):

{
  "message": "Your current plan does not include access to this feature: deals. Please upgrade your plan.",
  "error_code": "FEATURE_NOT_INCLUDED_IN_PLAN"
}

The identifier after feature: is the lowercase plan-feature id — deals, payments, workspace_payments, orders, campaigns, booking, email_marketing, or newsletters — not the product display name. Key on error_code: "FEATURE_NOT_INCLUDED_IN_PLAN", not on the message text.

All other resources — contacts (except the documents sub-route above), notes, tags, contact groups, products, templates, transactional emails, and webhook endpoints — require only plan-wide API access.

Authentication

Send the key as a bearer token on every request:

Authorization: Bearer otok_live_...
Problem Response
Missing or non-Bearer Authorization header 401"Missing API key"
Malformed, unknown, or revoked key 401"Invalid API key"
Plan without API access 403 — see above
Plan without the resource group's feature 403error_code: "FEATURE_NOT_INCLUDED_IN_PLAN" (see above)

A revoked key is indistinguishable from an unknown key — both return 401 "Invalid API key". If a previously working integration starts failing with this error, check whether the key was revoked.

First request

curl "https://app.otok.io/api/v1/contacts?limit=5" \
  -H "Authorization: Bearer otok_live_abc123..."
{
  "data": [
    {
      "id": "9c2f1a4e-3b7d-4e2a-9f0c-1d2e3f4a5b6c",
      "name": "Dana Levi",
      "phone": "+972501234567",
      "email": "dana@example.com",
      "lifecycle_stage": "lead",
      "tags": ["b1a2c3d4-0000-0000-0000-000000000001"],
      "groups": [],
      "created_at": "2026-07-01T09:15:00.000Z",
      "updated_at": "2026-07-10T14:03:00.000Z"
    }
  ],
  "total": 1,
  "limit": 5,
  "offset": 0
}

Error responses

The API uses two error body shapes. Handle errors by HTTP status code first, then inspect the body:

1. Domain errors (business-rule failures on the email and webhook-endpoint APIs, and failures of POST /v1/campaigns/:id/execute) use a structured envelope:

{ "error": { "code": "quota_exceeded", "message": "Monthly email quota exhausted" } }

Key on error.code — the message text is informative and may change.

2. Framework errors (validation failures, authentication, authorization, rate limiting, and most resource errors) use the standard shape:

{ "statusCode": 400, "message": ["name must be shorter than or equal to 200 characters"], "error": "Bad Request" }

message is a string for most errors and an array of per-field messages for request-validation failures.

3. error_code variants. Some 4xx responses extend the standard shape with a machine-readable error_code field (and occasionally extra fields), for example:

{
  "statusCode": 409,
  "error": "Conflict",
  "error_code": "CONTACT_MERGE_REQUIRED",
  "merge_request_id": "3a2b1c0d-...",
  "message": "The provided phone and email belong to two different existing contacts. A merge request has been opened — resolve it in the app, then retry."
}

When an error_code is documented for an endpoint, prefer it over matching on message text. Deals, payments, orders, bookings, and contacts use this style for their coded errors. CONTACT_MERGE_REQUIRED (with its merge_request_id) is returned by POST /v1/contacts and, with a different message, by PATCH /v1/contacts/:id — see Contacts. The feature-gating 403 also carries an error_code but omits statusCode.

Request validation

Request bodies are strictly validated:

Filter-value validation

On the list endpoints that accept the JSON filter parameter (contacts, campaigns, templates, tags, contact-groups, meeting-types) — and in audience_filters condition trees on campaign bodies — filter values are type-checked against the target field before the query runs. A mistyped value returns 400 Bad Request in the standard shape:

{ "statusCode": 400, "message": "<see below>", "error": "Bad Request" }
Field kind Example request 400 message
date/timestamp ?filter={"created_at":"not-a-date"} Invalid filter value for "created_at": "not-a-date" is not a date
UUID ?filter={"id":"abc"} Invalid filter value for "id": "abc" is not a UUID
enum ?filter={"lifecycle_stage":"bogus"} Invalid filter value for "lifecycle_stage": must be one of <allowed values, comma-separated>
numeric ?filter={"employee_count":"abc"} Invalid filter value for "employee_count": "abc" is not a number
boolean ?filter={"is_active":"maybe"} Invalid filter value for "is_active": "maybe" is not a boolean

Values that coerce cleanly are accepted: date-only strings are valid date filters (?filter={"created_at":"2026-01-01"} works), numeric strings are accepted for numeric fields ("125"125), and "true"/"false" strings are accepted for boolean fields.

Substring operators (contains, not_contains, starts_with, ends_with) in $where condition trees are rejected on non-text fields:

{
  "statusCode": 400,
  "message": "Invalid filter on \"id\": substring operators (contains, starts_with, ends_with) only apply to text fields",
  "error": "Bad Request"
}

Duplicate names (409)

Tag and contact-group names are unique per workspace (case-insensitive). POST/PATCH with a name that already exists returns 409 Conflict in the standard shape:

{ "statusCode": 409, "message": "A tag with this name already exists", "error": "Conflict" }

("A contact group with this name already exists" for groups.) See Tags & Contact Groups.

Rate limits

Scope Limit
Per API key (default, all endpoints) 100 requests / minute
POST /v1/emails 300 requests / minute per key
Per source IP, across all API-key traffic 300 requests / minute

The per-key bucket is shared across all source IPs using that key. Exceeding a limit returns HTTP 429 with a Retry-After header indicating how long to wait. Spread bursts out and honor Retry-After with exponential backoff.

List conventions

Most list endpoints (contacts, tags, contact-groups, campaigns, templates, meeting-types) accept:

Param Type Default Notes
filter JSON object (string-encoded) {} Invalid JSON → 400 "Invalid filter: must be valid JSON"; a non-object (array/scalar) → 400 "Invalid filter: must be a JSON object". Filter values are type-checked against the target field — a mistyped value returns 400 (see filter-value validation). Any workspace_id in the filter is ignored and replaced with your authenticated workspace.
sort string -created_at Field name; - prefix for descending.
limit integer ≥ 0 50 Hard cap 500. Non-integer or negative → 400.
offset integer ≥ 0 0 Non-integer or negative → 400.
search string Free-text search (fields vary per resource).

List responses use a consistent envelope:

{ "data": [ ... ], "total": 123, "limit": 50, "offset": 0 }

total is the count of all matches, so offset + limit < total means there are more pages.

GET /v1/bookings does not accept filter or search — it uses dedicated query parameters (status, meeting_type_id, from/to) with the standard limit/offset and a -start_at sort default — see Bookings.

Filter grammar

The filter object supports:

Example — contacts in a group, subscribed to email:

curl -G "https://app.otok.io/api/v1/contacts" \
  -H "Authorization: Bearer otok_live_abc123..." \
  --data-urlencode 'filter={"$jsonb_contains":{"groups":["7f6e5d4c-0000-0000-0000-000000000002"]},"email_subscribed":true}'

Where deals and payments differ

GET /v1/deals, GET /v1/payments, and GET /v1/payment-requests use dedicated query parameters instead of filter, and paginate differently:

GET /v1/orders belongs to the same family (dedicated query parameters, default 25 / cap 100, no filter), with three differences:

The email-marketing lists — GET /v1/email-campaigns, GET /v1/newsletters, and GET /v1/newsletters/:id/issues — paginate exactly like deals/payments (default 25, cap 100, malformed limit/offset → 400) with dedicated query parameters and no filter/sort/search. Like GET /v1/payment-requests, the campaign and issue lists reject an unknown status value with 400.

See Deals, Payments, Orders, Email Campaigns, and Newsletters.

Success status codes

CORS and calling context

The API is designed for server-to-server use. Call it from backend code (curl, server jobs) — origin-less requests are accepted. Browser-based calls from arbitrary origins are rejected by CORS; never embed an API key in client-side code.

Request logging

Every authenticated /v1 request that reaches an endpoint is recorded (method, path, status, duration, IP, user agent — metadata only, never bodies). Workspace admins can review this log in Settings → Developers.

Next steps