oToK REST API (1.6.0)

Download OpenAPI specification:

URL: https://app.otok.io License: MIT

Public REST API for the oToK multichannel marketing communication platform.

The oToK REST API (/v1) gives programmatic access to your workspace: contacts (including per-channel marketing consent), tags and groups, WhatsApp campaigns and templates, deals and pipelines, the product catalog, payments, payment requests (pay-links), orders, transactional email, broadcast email campaigns, newsletters, email suppressions, read-only audience and sender-profile discovery, outbound webhooks, and bookings.

Authentication

Send a workspace API key as a bearer token on every request:

Authorization: Bearer otok_live_...

Keys are created in Settings → Developers in the oToK app and are shown exactly once. A revoked key is indistinguishable from an unknown key — both return 401 "Invalid API key". The REST API requires a plan with API access (Growth or higher); other plans receive 403.

Endpoint groups that mirror a plan-gated product area additionally require that feature on the workspace's plan — deals/pipelines (deals), payments and contact documents (payments), payment requests (workspace_payments), orders (orders), campaigns (campaigns), email campaigns (email_marketing), newsletters (newsletters), bookings/meeting-types (booking). Every route in a gated group answers 403 with error_code: "FEATURE_NOT_INCLUDED_IN_PLAN" (a body without a statusCode field) when the plan lacks the feature. Note the two distinct payment gates: /v1/payments* and GET /v1/contacts/{id}/documents require the payments feature (the payments ledger), while /v1/payment-requests* requires workspace_payments (collecting money through the workspace's own connected payment provider) — a workspace can hold either feature without the other, and the 403 message embeds whichever feature id is missing. The suppression list (/v1/suppressions*) and the sender-profile listing (GET /v1/sender-profiles) require the email_marketing feature. Contacts (including consent), notes, tags, contact groups, templates, products, audiences, emails, and webhook endpoints require only plan-wide API access.

Error shapes

The API uses two error body shapes:

  1. Domain errors (email, email-campaign, newsletter, webhook-endpoint, consent, suppression, and product APIs, and POST /v1/campaigns/{id}/execute failures) use a structured envelope: {"error": {"code", "message"}} — key on error.code.
  2. Framework errors (validation, auth, rate limiting, most resource errors) use {"statusCode", "message", "error"}, where message is a string or an array of per-field validation messages. Some 4xx responses extend this shape with a machine-readable error_code field (deals, payments, orders, bookings, contacts).

Request bodies are strictly validated: unknown body properties are rejected with 400. Path :id parameters must be UUIDs; a non-UUID id returns 400 (except DELETE /v1/webhook-endpoints/{id}, which returns 404).

Deletion

The API never deletes customer data — contacts, deals, products, payments, orders, campaigns, tags, and contact groups have no DELETE routes (deactivate a product with is_active: false instead). The only DELETE endpoints are for resources the API owns: DELETE /v1/notes/{id}, DELETE /v1/webhook-endpoints/{id}, DELETE /v1/suppressions/{id} (which lifts a send-time block — it deletes no contact data and resubscribes no one), and DELETE /v1/newsletter-issues/{id} (drafts and scheduled issues only — published issues are never deletable).

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

Exceeding a limit returns HTTP 429 with a Retry-After header. The per-key bucket is shared across all source IPs using that key.

Pagination

Most list endpoints return { "data", "total", "limit", "offset" } with limit default 50 (hard cap 500). GET /v1/deals, GET /v1/payments, GET /v1/payment-requests, GET /v1/orders, and the email-campaign/newsletter/audience/sender-profile lists use dedicated query parameters with limit default 25 (cap 100); absent or empty limit/offset values default. On deals, payments, payment requests, and the email-campaign/newsletter/audience/sender-profile lists, malformed values return 400 ("Invalid limit: must be a non-negative integer"); on orders, paging never returns 400 — malformed or zero values silently default and out-of-range values are clamped. Unrecognized enum filter values on deals/payments/orders are silently ignored; GET /v1/payment-requests, GET /v1/email-campaigns, and GET /v1/newsletters/{id}/issues instead reject an unknown status with 400 (as does GET /v1/audiences for an unknown kind).

Idempotency

  • Contacts upsert by phone/email.
  • Deals, payments, and orders upsert by external_reference; products upsert by external_id.
  • Email campaigns and newsletter issues upsert by external_reference — a replay updates content/fields while the record is still editable and never touches status, scheduled_at, or an issue's number.
  • Suppression adds are idempotent per address (re-adding answers duplicate: true).
  • Order refunds are idempotent per external_refund_id — only when you supply one.
  • Emails require an explicit idempotency_key.
  • Booking creation is idempotent per slot + meeting type + contact (server-derived, no key).
  • Payment requests have NO idempotency keyPOST /v1/payment-requests is not idempotent: a repeat POST mints a second, independently payable link. Cancel extras with POST /v1/payment-requests/{id}/cancel.

Idempotent create responses carry a top-level boolean duplicate field — false on a fresh create, true when the call matched an existing record — with one exception: POST /v1/orders returns the same full order body for both outcomes and no duplicate marker (compare created_at, or pre-check with GET /v1/orders?external_reference=...); the order refund endpoint does return { "duplicate", "order" }. Status codes are 201 for both outcomes everywhere except POST /v1/emails, which answers 201 fresh / 200 replay.

Changes in 1.6.0

  • New Email campaigns resource group (/v1/email-campaigns*) — author, estimate, launch, and schedule broadcast email campaigns: idempotent create via external_reference, PATCH while draft/scheduled, GET …/estimate through the send path's audience resolver, and send/schedule/unschedule riding the exact in-app launch gates. Requires the email_marketing plan feature. A/B testing is deliberately not exposed — create A/B campaigns in-app.
  • New Newsletters resource group (/v1/newsletters* + /v1/newsletter-issues*) — list/create newsletters (rows carry active subscriber counts) and author their issues: idempotent issue create via external_reference, publish/schedule/unschedule with publish-time issue numbering, and deletion for never-published issues. Requires the newsletters plan feature.
  • Shared content authoring contract on both new surfaces: exactly one of markdown (CommonMark subset + ::button[Label](https://url) / ::snippet[name-or-uuid] directives + [[…]] personalization tokens), typed blocks, or a raw design_json document — compiled at write time, with the result reported in the response's compile: {ok, errors, warnings} envelope.
  • Two new read-only targeting-selector discovery endpoints: GET /v1/audiences lists the saved audiences campaigns accept as audience_id (rows carry the summary columns and advisory size cache — never the stored definition), and GET /v1/sender-profiles lists the email from-identities email campaigns accept as sender_profile_id, each with a verified send-readiness signal (requires the email_marketing plan feature; audiences need only plan-wide API access). Neither resource is writable through the public API.

Changes in 1.5.0

  • New GET /v1/meeting-types/{id}/embed — website-embed material for a meeting type: the hosted booking page URL, the workspace's publishable embed key (bk_…, safe in page HTML — not the secret API key), and a ready-to-paste snippet. Requires the booking plan feature.

Changes in 1.4.0

  • New consent endpoints on contacts: GET /v1/contacts/{id}/consent reads both channels' stored consent decision + provider-owned deliverability (email adds the composed send-time suppressed verdict), and PUT /v1/contacts/{id}/consent/{channel} records a subscribed/unsubscribed decision with its provenance (evidence trail included; a channel with a spam complaint on record answers 409 consent_sticky_complained).
  • New Suppressions resource group (/v1/suppressions*) — list, idempotently add, and remove workspace email-suppression rows. Requires the email_marketing plan feature. Suppression and consent deliberately compose at send time: adding a suppression does not change consent, and removing one resubscribes no one.
  • New Products resource group (/v1/products*) — the workspace product catalog shared by deals and payments: list/get/create/update with an idempotent POST upsert via external_id and no DELETE (deactivate with is_active: false).
  • Fifteen new webhook event types across six opt-in families, riding the existing endpoint registry, signing, and retry policy: contact lifecycle + consent (contact.created, contact.updated, contact.deleted, contact.consent_changed), inbound messages (message.received), deal lifecycle (deal.created, deal.stage_changed, deal.won, deal.lost), booking lifecycle (booking.created, booking.rescheduled, booking.cancelled, booking.reassigned), event attendance (event.attendance.changed), and form submissions (form.submitted). All are opt-in by listing — the default subscription is unchanged, and a pre-existing endpoint never starts receiving a new family unasked.

Changes in 1.3.0

  • New Payment requests resource group (/v1/payment-requests*) — hosted pay-links collected through the workspace's own connected payment provider (Cardcom / Sumit) — and the four payment_request.* webhook event types (opt-in by listing, like the order events). Gated by the workspace_payments plan feature (distinct from the payments ledger gate).
  • New GET /v1/contacts/{id}/documents — read-only listing of a contact's financial documents (invoices, receipts, credit documents), with an opt-in ?live=true provider lookup. Gated by the payments feature.
  • Payments: POST /v1/payments and PATCH /v1/payments/{id} accept a vat_mode + vat_rate pair (recurring plans only, always together) and a free-form metadata object (≤ 2048 bytes serialized; replace-on-write, null clears on PATCH). Payment and payment-entry response fields are now fully documented, including the recurring auto-charging/dunning fields, the stored VAT pair, and entry-level refund/credit-document fields.

Changes in 1.2.0

  • New Orders resource group (/v1/orders*) and the five order.* webhook event types.
  • Schema correction for existing consumers: deal and payment response money — Deal.amount, Payment.total_amount, and payment entry amount/recognized_amount — is now typed as a JSON number. Spec versions up to 1.1.0 incorrectly documented these response fields as decimal strings (e.g. "3600.00"); the API has always returned JSON numbers (e.g. 3600), so nothing changed on the wire — but clients generated from the earlier schema will see these declared types change from string to number on regeneration.

Contacts

Contact upsert & update, plus the merge-conflict contract. There is no DELETE endpoint for contacts.

List contacts

Standard list conventions apply. 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.

Contacts support extra filter operators: $jsonb_contains for tags/groups (by UUID), $event_attendance ({"event_id": "...", "status": "attended"}), and the virtual consent fields whatsapp_subscribed, email_subscribed (boolean), whatsapp_deliverability, email_deliverability.

Authorizations:
bearerAuth
query Parameters
filter
string
Default: "{}"
Example: filter={"lifecycle_stage":"lead"}

String-encoded JSON object. Invalid JSON → 400 "Invalid filter: must be valid JSON"; a non-object → 400 "Invalid filter: must be a JSON object". Any workspace_id in the filter is ignored and replaced with your authenticated workspace.

Grammar: exact match ({"lifecycle_stage": "lead"}; array values match any member), $where (nested condition tree, max 100 nodes, max 200 items per array value), and $gt (the only top-level comparison shortcut). Contacts additionally support $jsonb_contains, $event_attendance, and virtual consent fields. Note: the full set of $where operators is not exhaustively enumerated in the public docs — see the Getting Started guide for the documented examples.

Filter values are type-checked against the target field before the query runs — a mistyped value returns 400 Invalid filter value for "<field>": … (per field kind: not a date / not a UUID / must be one of / not a number / not a boolean). Date-only strings ("2026-01-01"), numeric strings ("125"), and "true"/"false" strings are accepted and coerced. Substring operators (contains, not_contains, starts_with, ends_with) in $where trees are rejected on non-text fields (400 Invalid filter on "<field>": substring operators (contains, starts_with, ends_with) only apply to text fields).

sort
string
Default: "-created_at"

Field name; - prefix for descending.

limit
integer [ 0 .. 500 ]
Default: 50

Hard cap 500. Non-integer or negative → 400.

offset
integer >= 0
Default: 0

Non-integer or negative → 400.

search
string

Free-text search (fields vary per resource).

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "total": 0,
  • "limit": 0,
  • "offset": 0
}

Upsert a contact by phone/email

Creates a contact, or updates the existing contact that the given phone/email resolves to. Resolution is history-aware and phone wins over email. On a match, scalar fields overwrite, custom_fields shallow-merge, and tags/groups are added to the existing set (never removed by this route).

The response is 201 in both cases (create and update) and carries a top-level boolean duplicate field — false when this request created the contact, true when it matched and updated an existing one.

If the phone resolves to one existing contact and the email to a different one, the API does not write: it opens a merge request and returns 409 CONTACT_MERGE_REQUIRED. The non-identity fields of the request are parked with the merge request and applied when it is resolved.

Authorizations:
bearerAuth
Request Body schema: application/json
required
phone
string <= 32 characters

Normalized to E.164 (local numbers use the workspace default country).

name
string <= 200 characters
first_name
string <= 100 characters
last_name
string <= 100 characters
email
string <email> <= 255 characters
avatar_url
string <= 500 characters
notes
string <= 5000 characters
lifecycle_stage
string
Enum: "lead" "prospect" "customer" "inactive" "archived"
source
string
Enum: "manual" "import" "widget" "campaign" "api" "form"
block_state
string
Enum: "none" "workspace" "global"

Changes route through the consent/blocking subsystem. Global blocks cannot be lifted via the API. A non-none value without a blacklist match is recorded with an import block source.

company_name
string <= 200 characters
vat_number
string <= 40 characters
job_title
string <= 120 characters
industry
string <= 80 characters
company_website
string <= 500 characters
annual_revenue
number
employee_count
integer [ 0 .. 2147483647 ]
currency_preference
string <= 8 characters
address_line1
string <= 200 characters
address_line2
string <= 200 characters
city
string <= 100 characters
state
string <= 100 characters
country
string <= 100 characters
postal_code
string <= 20 characters
gender
string
Enum: "male" "female" "other" "prefer_not_to_say"
date_of_birth
string <date>

ISO 8601 date.

language
string <= 12 characters
utm_source
string <= 200 characters
utm_medium
string <= 200 characters
utm_campaign
string <= 200 characters
utm_term
string <= 200 characters
utm_content
string <= 200 characters
gclid
string <= 200 characters
fbclid
string <= 200 characters
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
string <= 500 characters
facebook_url
string <= 500 characters
instagram_handle
string <= 100 characters
twitter_handle
string <= 100 characters
object

Arbitrary keys; shallow-merged into the existing object on update. Set a key to null to remove it.

tags
Array of strings[ items [ 1 .. 100 ] characters ]

Tag names. Missing tags are auto-created.

groups
Array of strings[ items [ 1 .. 100 ] characters ]

Group names. Missing groups are auto-created.

Responses

Request samples

Content type
application/json
{
  • "phone": "string",
  • "name": "string",
  • "first_name": "string",
  • "last_name": "string",
  • "email": "user@example.com",
  • "avatar_url": "string",
  • "notes": "string",
  • "lifecycle_stage": "lead",
  • "source": "manual",
  • "block_state": "none",
  • "company_name": "string",
  • "vat_number": "string",
  • "job_title": "string",
  • "industry": "string",
  • "company_website": "string",
  • "annual_revenue": 0,
  • "employee_count": 2147483647,
  • "currency_preference": "string",
  • "address_line1": "string",
  • "address_line2": "string",
  • "city": "string",
  • "state": "string",
  • "country": "string",
  • "postal_code": "string",
  • "gender": "male",
  • "date_of_birth": "2019-08-24",
  • "language": "string",
  • "utm_source": "string",
  • "utm_medium": "string",
  • "utm_campaign": "string",
  • "utm_term": "string",
  • "utm_content": "string",
  • "gclid": "string",
  • "fbclid": "string",
  • "lead_score": 0,
  • "linkedin_url": "string",
  • "facebook_url": "string",
  • "instagram_handle": "string",
  • "twitter_handle": "string",
  • "custom_fields": { },
  • "tags": [
    ],
  • "groups": [
    ]
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "phone": "string",
  • "name": "string",
  • "first_name": "string",
  • "last_name": "string",
  • "email": "string",
  • "avatar_url": "string",
  • "notes": "string",
  • "lifecycle_stage": "lead",
  • "source": "manual",
  • "block_state": "none",
  • "company_name": "string",
  • "vat_number": "string",
  • "job_title": "string",
  • "industry": "string",
  • "company_website": "string",
  • "annual_revenue": 0,
  • "employee_count": 0,
  • "currency_preference": "string",
  • "address_line1": "string",
  • "address_line2": "string",
  • "city": "string",
  • "state": "string",
  • "country": "string",
  • "postal_code": "string",
  • "gender": "male",
  • "date_of_birth": "2019-08-24",
  • "language": "string",
  • "utm_source": "string",
  • "utm_medium": "string",
  • "utm_campaign": "string",
  • "utm_term": "string",
  • "utm_content": "string",
  • "gclid": "string",
  • "fbclid": "string",
  • "lead_score": 0,
  • "linkedin_url": "string",
  • "facebook_url": "string",
  • "instagram_handle": "string",
  • "twitter_handle": "string",
  • "custom_fields": { },
  • "tags": [
    ],
  • "groups": [
    ],
  • "event_attendances": [
    ],
  • "whatsapp_subscribed": true,
  • "email_subscribed": true,
  • "whatsapp_deliverability": "string",
  • "email_deliverability": "string",
  • "score_band": "cold",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "duplicate": true
}

Get one contact

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "phone": "string",
  • "name": "string",
  • "first_name": "string",
  • "last_name": "string",
  • "email": "string",
  • "avatar_url": "string",
  • "notes": "string",
  • "lifecycle_stage": "lead",
  • "source": "manual",
  • "block_state": "none",
  • "company_name": "string",
  • "vat_number": "string",
  • "job_title": "string",
  • "industry": "string",
  • "company_website": "string",
  • "annual_revenue": 0,
  • "employee_count": 0,
  • "currency_preference": "string",
  • "address_line1": "string",
  • "address_line2": "string",
  • "city": "string",
  • "state": "string",
  • "country": "string",
  • "postal_code": "string",
  • "gender": "male",
  • "date_of_birth": "2019-08-24",
  • "language": "string",
  • "utm_source": "string",
  • "utm_medium": "string",
  • "utm_campaign": "string",
  • "utm_term": "string",
  • "utm_content": "string",
  • "gclid": "string",
  • "fbclid": "string",
  • "lead_score": 0,
  • "linkedin_url": "string",
  • "facebook_url": "string",
  • "instagram_handle": "string",
  • "twitter_handle": "string",
  • "custom_fields": { },
  • "tags": [
    ],
  • "groups": [
    ],
  • "event_attendances": [
    ],
  • "whatsapp_subscribed": true,
  • "email_subscribed": true,
  • "whatsapp_deliverability": "string",
  • "email_deliverability": "string",
  • "score_band": "cold",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Update a contact by id

Same field set and validation as the POST upsert (all optional), with different semantics:

  • tags/groups replace the full set (empty array clears; names auto-create).
  • custom_fields still shallow-merge — set a key to null to remove it.
  • name and first_name/last_name stay in sync.
  • block_state changes route through the consent/blocking subsystem; global blocks cannot be lifted via the API.

Setting phone/email to an identifier that belongs to a different contact does not apply the write: like the POST upsert's conflict path, a merge request is opened and the response is 409 CONTACT_MERGE_REQUIRED. Non-identity fields sent in the same PATCH are held on the merge request and applied when it is resolved. The conflict check is history-aware: an identifier no contact currently holds but that another contact previously held also parks a merge request and returns the 409.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Request Body schema: application/json
required
phone
string <= 32 characters

Normalized to E.164 (local numbers use the workspace default country).

name
string <= 200 characters
first_name
string <= 100 characters
last_name
string <= 100 characters
email
string <email> <= 255 characters
avatar_url
string <= 500 characters
notes
string <= 5000 characters
lifecycle_stage
string
Enum: "lead" "prospect" "customer" "inactive" "archived"
source
string
Enum: "manual" "import" "widget" "campaign" "api" "form"
block_state
string
Enum: "none" "workspace" "global"

Changes route through the consent/blocking subsystem. Global blocks cannot be lifted via the API. A non-none value without a blacklist match is recorded with an import block source.

company_name
string <= 200 characters
vat_number
string <= 40 characters
job_title
string <= 120 characters
industry
string <= 80 characters
company_website
string <= 500 characters
annual_revenue
number
employee_count
integer [ 0 .. 2147483647 ]
currency_preference
string <= 8 characters
address_line1
string <= 200 characters
address_line2
string <= 200 characters
city
string <= 100 characters
state
string <= 100 characters
country
string <= 100 characters
postal_code
string <= 20 characters
gender
string
Enum: "male" "female" "other" "prefer_not_to_say"
date_of_birth
string <date>

ISO 8601 date.

language
string <= 12 characters
utm_source
string <= 200 characters
utm_medium
string <= 200 characters
utm_campaign
string <= 200 characters
utm_term
string <= 200 characters
utm_content
string <= 200 characters
gclid
string <= 200 characters
fbclid
string <= 200 characters
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
string <= 500 characters
facebook_url
string <= 500 characters
instagram_handle
string <= 100 characters
twitter_handle
string <= 100 characters
object

Arbitrary keys; shallow-merged into the existing object on update. Set a key to null to remove it.

tags
Array of strings[ items [ 1 .. 100 ] characters ]

Tag names. Missing tags are auto-created.

groups
Array of strings[ items [ 1 .. 100 ] characters ]

Group names. Missing groups are auto-created.

Responses

Request samples

Content type
application/json
{
  • "phone": "string",
  • "name": "string",
  • "first_name": "string",
  • "last_name": "string",
  • "email": "user@example.com",
  • "avatar_url": "string",
  • "notes": "string",
  • "lifecycle_stage": "lead",
  • "source": "manual",
  • "block_state": "none",
  • "company_name": "string",
  • "vat_number": "string",
  • "job_title": "string",
  • "industry": "string",
  • "company_website": "string",
  • "annual_revenue": 0,
  • "employee_count": 2147483647,
  • "currency_preference": "string",
  • "address_line1": "string",
  • "address_line2": "string",
  • "city": "string",
  • "state": "string",
  • "country": "string",
  • "postal_code": "string",
  • "gender": "male",
  • "date_of_birth": "2019-08-24",
  • "language": "string",
  • "utm_source": "string",
  • "utm_medium": "string",
  • "utm_campaign": "string",
  • "utm_term": "string",
  • "utm_content": "string",
  • "gclid": "string",
  • "fbclid": "string",
  • "lead_score": 0,
  • "linkedin_url": "string",
  • "facebook_url": "string",
  • "instagram_handle": "string",
  • "twitter_handle": "string",
  • "custom_fields": { },
  • "tags": [
    ],
  • "groups": [
    ]
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "phone": "string",
  • "name": "string",
  • "first_name": "string",
  • "last_name": "string",
  • "email": "string",
  • "avatar_url": "string",
  • "notes": "string",
  • "lifecycle_stage": "lead",
  • "source": "manual",
  • "block_state": "none",
  • "company_name": "string",
  • "vat_number": "string",
  • "job_title": "string",
  • "industry": "string",
  • "company_website": "string",
  • "annual_revenue": 0,
  • "employee_count": 0,
  • "currency_preference": "string",
  • "address_line1": "string",
  • "address_line2": "string",
  • "city": "string",
  • "state": "string",
  • "country": "string",
  • "postal_code": "string",
  • "gender": "male",
  • "date_of_birth": "2019-08-24",
  • "language": "string",
  • "utm_source": "string",
  • "utm_medium": "string",
  • "utm_campaign": "string",
  • "utm_term": "string",
  • "utm_content": "string",
  • "gclid": "string",
  • "fbclid": "string",
  • "lead_score": 0,
  • "linkedin_url": "string",
  • "facebook_url": "string",
  • "instagram_handle": "string",
  • "twitter_handle": "string",
  • "custom_fields": { },
  • "tags": [
    ],
  • "groups": [
    ],
  • "event_attendances": [
    ],
  • "whatsapp_subscribed": true,
  • "email_subscribed": true,
  • "whatsapp_deliverability": "string",
  • "email_deliverability": "string",
  • "score_band": "cold",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Get a contact's per-channel consent

Returns both channels (whatsapp + email) at once. A channel without a stored decision reads consent_state/deliverability "unknown" — treat unknown as not sendable. The email channel additionally carries the composed send-time suppressed verdict (suppression list + blacklist + deliverability), which is independent of the consent decision.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "block_state": "none",
  • "channels": {
    }
}

Set a contact's consent on one channel

Records a subscribed/unsubscribed decision with its provenance — the same path every in-app consent change takes, so the consent-evidence ledger and activity timeline apply identically. "unknown" is a system state and cannot be set, and deliverability is provider-owned (bounce/complaint feedback) and is not writable. Subscribing a channel whose deliverability is complained is refused with 409 consent_sticky_complained — a spam complaint can never be lifted through the API.

Returns the resulting channel object in the same shape GET uses, so the caller sees the applied state, stamped provenance, and deliverability (the email channel includes the suppressed verdict).

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

channel
required
string
Enum: "whatsapp" "email"

whatsapp or email. Any other value returns 400 invalid_channel.

Request Body schema: application/json
required
state
required
string
Enum: "subscribed" "unsubscribed"

The consent decision to record. "unknown" is a system state and cannot be set.

basis
string
Enum: "express_opt_in" "double_opt_in" "soft_opt_in" "implied" "imported"

Legal basis of the decision. Defaults to express_opt_in when subscribing.

source
string <= 100 characters

Where the decision came from in your system; recorded as api:<source> in the consent evidence trail (plain api when omitted).

expires_at
string <date-time>

When this consent expires (ISO 8601).

ip
string <= 64 characters

End-user IP captured with the decision (evidence trail).

user_agent
string <= 512 characters

End-user user agent captured with the decision (evidence trail).

Responses

Request samples

Content type
application/json
{
  • "state": "subscribed",
  • "basis": "express_opt_in",
  • "source": "string",
  • "expires_at": "2019-08-24T14:15:22Z",
  • "ip": "string",
  • "user_agent": "string"
}

Response samples

Content type
application/json
{
  • "consent_state": "subscribed",
  • "consent_basis": "express_opt_in",
  • "consent_source": "string",
  • "consent_at": "2019-08-24T14:15:22Z",
  • "consent_expires_at": "2019-08-24T14:15:22Z",
  • "deliverability": "unknown",
  • "unsubscribed_at": "2019-08-24T14:15:22Z",
  • "complained_at": "2019-08-24T14:15:22Z",
  • "last_bounce_at": "2019-08-24T14:15:22Z",
  • "suppressed": true,
  • "suppression_reason": "string"
}

Contact notes

Plain-text notes on a contact. API note payloads are text only.

List a contact's notes

Returns a JSON array of all the contact's notes — not paginated, no query parameters. Pinned notes come first (most recently pinned on top), then the rest newest-first. author_name is included on list responses only.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Add a note

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.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Request Body schema: application/json
required
body
required
string <= 5000 characters

Trimmed — empty after trim → 400 "Note body cannot be empty".

pinned
boolean

true pins the note immediately.

Responses

Request samples

Content type
application/json
{
  • "body": "string",
  • "pinned": true
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "author_user_id": "13209de9-6c50-42f7-a1fd-5bf87d7b9225",
  • "author_name": "string",
  • "source": "string",
  • "body": "string",
  • "body_json": { },
  • "mentioned_user_ids": [
    ],
  • "pinned_at": "2019-08-24T14:15:22Z",
  • "conversation_id": "cc71b11a-25cd-4c2d-9950-df2cc38e3407",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Edit / pin a note

Both fields optional; sending neither returns the current note unchanged. A body change bumps updated_at; 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 field-change automations).

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Request Body schema: application/json
required
body
string <= 5000 characters

Empty → 400.

pinned
boolean

Pin/unpin.

Responses

Request samples

Content type
application/json
{
  • "body": "string",
  • "pinned": true
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "author_user_id": "13209de9-6c50-42f7-a1fd-5bf87d7b9225",
  • "author_name": "string",
  • "source": "string",
  • "body": "string",
  • "body_json": { },
  • "mentioned_user_ids": [
    ],
  • "pinned_at": "2019-08-24T14:15:22Z",
  • "conversation_id": "cc71b11a-25cd-4c2d-9950-df2cc38e3407",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Delete a note

Deleting the contact's profile note also clears the contact's notes field. A deletion breadcrumb is kept in the contact's activity timeline.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "success": true
}

Tags

Tag CRUD (no delete). Names are unique per workspace (case-insensitive).

List tags

Standard list conventions; search matches the name field.

Authorizations:
bearerAuth
query Parameters
filter
string
Default: "{}"
Example: filter={"lifecycle_stage":"lead"}

String-encoded JSON object. Invalid JSON → 400 "Invalid filter: must be valid JSON"; a non-object → 400 "Invalid filter: must be a JSON object". Any workspace_id in the filter is ignored and replaced with your authenticated workspace.

Grammar: exact match ({"lifecycle_stage": "lead"}; array values match any member), $where (nested condition tree, max 100 nodes, max 200 items per array value), and $gt (the only top-level comparison shortcut). Contacts additionally support $jsonb_contains, $event_attendance, and virtual consent fields. Note: the full set of $where operators is not exhaustively enumerated in the public docs — see the Getting Started guide for the documented examples.

Filter values are type-checked against the target field before the query runs — a mistyped value returns 400 Invalid filter value for "<field>": … (per field kind: not a date / not a UUID / must be one of / not a number / not a boolean). Date-only strings ("2026-01-01"), numeric strings ("125"), and "true"/"false" strings are accepted and coerced. Substring operators (contains, not_contains, starts_with, ends_with) in $where trees are rejected on non-text fields (400 Invalid filter on "<field>": substring operators (contains, starts_with, ends_with) only apply to text fields).

sort
string
Default: "-created_at"

Field name; - prefix for descending.

limit
integer [ 0 .. 500 ]
Default: 50

Hard cap 500. Non-integer or negative → 400.

offset
integer >= 0
Default: 0

Non-integer or negative → 400.

search
string

Free-text search (fields vary per resource).

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "total": 0,
  • "limit": 0,
  • "offset": 0
}

Create a tag

Tag names are unique per workspace (case-insensitive). A name that already exists returns 409 Conflict — look up the existing record (list with search=<name>) and reuse it.

Authorizations:
bearerAuth
Request Body schema: application/json
required
name
string [ 1 .. 100 ] characters
color
string <= 20 characters
type
string
Default: "both"
Enum: "contact" "conversation" "both"

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "color": "string",
  • "type": "contact"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "name": "string",
  • "color": "string",
  • "type": "contact",
  • "usage_count": 0,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Get one tag

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "name": "string",
  • "color": "string",
  • "type": "contact",
  • "usage_count": 0,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Update a tag

Same fields as create, all optional.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Request Body schema: application/json
required
name
string [ 1 .. 100 ] characters
color
string <= 20 characters
type
string
Default: "both"
Enum: "contact" "conversation" "both"

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "color": "string",
  • "type": "contact"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "name": "string",
  • "color": "string",
  • "type": "contact",
  • "usage_count": 0,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Contact groups

Contact group CRUD (no delete). Names are unique per workspace (case-insensitive).

List groups

Standard list conventions; search matches the name field.

Authorizations:
bearerAuth
query Parameters
filter
string
Default: "{}"
Example: filter={"lifecycle_stage":"lead"}

String-encoded JSON object. Invalid JSON → 400 "Invalid filter: must be valid JSON"; a non-object → 400 "Invalid filter: must be a JSON object". Any workspace_id in the filter is ignored and replaced with your authenticated workspace.

Grammar: exact match ({"lifecycle_stage": "lead"}; array values match any member), $where (nested condition tree, max 100 nodes, max 200 items per array value), and $gt (the only top-level comparison shortcut). Contacts additionally support $jsonb_contains, $event_attendance, and virtual consent fields. Note: the full set of $where operators is not exhaustively enumerated in the public docs — see the Getting Started guide for the documented examples.

Filter values are type-checked against the target field before the query runs — a mistyped value returns 400 Invalid filter value for "<field>": … (per field kind: not a date / not a UUID / must be one of / not a number / not a boolean). Date-only strings ("2026-01-01"), numeric strings ("125"), and "true"/"false" strings are accepted and coerced. Substring operators (contains, not_contains, starts_with, ends_with) in $where trees are rejected on non-text fields (400 Invalid filter on "<field>": substring operators (contains, starts_with, ends_with) only apply to text fields).

sort
string
Default: "-created_at"

Field name; - prefix for descending.

limit
integer [ 0 .. 500 ]
Default: 50

Hard cap 500. Non-integer or negative → 400.

offset
integer >= 0
Default: 0

Non-integer or negative → 400.

search
string

Free-text search (fields vary per resource).

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "total": 0,
  • "limit": 0,
  • "offset": 0
}

Create a group

Group names are unique per workspace (case-insensitive). A name that already exists returns 409 Conflict.

Authorizations:
bearerAuth
Request Body schema: application/json
required
name
string [ 1 .. 100 ] characters
description
string <= 500 characters
color
string <= 20 characters

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "description": "string",
  • "color": "string"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "name": "string",
  • "description": "string",
  • "color": "string",
  • "is_system": true,
  • "contact_count": 0,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Get one group

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "name": "string",
  • "description": "string",
  • "color": "string",
  • "is_system": true,
  • "contact_count": 0,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Update a group

Same fields as create, all optional.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Request Body schema: application/json
required
name
string [ 1 .. 100 ] characters
description
string <= 500 characters
color
string <= 20 characters

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "description": "string",
  • "color": "string"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "name": "string",
  • "description": "string",
  • "color": "string",
  • "is_system": true,
  • "contact_count": 0,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Campaigns

WhatsApp campaigns — create, schedule, execute.

List campaigns

Standard list conventions. List rows omit variables and audience_filters — fetch a single campaign to read them.

Authorizations:
bearerAuth
query Parameters
filter
string
Default: "{}"
Example: filter={"lifecycle_stage":"lead"}

String-encoded JSON object. Invalid JSON → 400 "Invalid filter: must be valid JSON"; a non-object → 400 "Invalid filter: must be a JSON object". Any workspace_id in the filter is ignored and replaced with your authenticated workspace.

Grammar: exact match ({"lifecycle_stage": "lead"}; array values match any member), $where (nested condition tree, max 100 nodes, max 200 items per array value), and $gt (the only top-level comparison shortcut). Contacts additionally support $jsonb_contains, $event_attendance, and virtual consent fields. Note: the full set of $where operators is not exhaustively enumerated in the public docs — see the Getting Started guide for the documented examples.

Filter values are type-checked against the target field before the query runs — a mistyped value returns 400 Invalid filter value for "<field>": … (per field kind: not a date / not a UUID / must be one of / not a number / not a boolean). Date-only strings ("2026-01-01"), numeric strings ("125"), and "true"/"false" strings are accepted and coerced. Substring operators (contains, not_contains, starts_with, ends_with) in $where trees are rejected on non-text fields (400 Invalid filter on "<field>": substring operators (contains, starts_with, ends_with) only apply to text fields).

sort
string
Default: "-created_at"

Field name; - prefix for descending.

limit
integer [ 0 .. 500 ]
Default: 50

Hard cap 500. Non-integer or negative → 400.

offset
integer >= 0
Default: 0

Non-integer or negative → 400.

search
string

Free-text search (fields vary per resource).

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "total": 0,
  • "limit": 0,
  • "offset": 0
}

Create a campaign

If the campaign is saved with status: "scheduled" and a scheduled_at, it is automatically queued to execute at scheduled_at — you do not need to call /execute. Any change to the campaign re-syncs the schedule.

At send time, the audience can only narrow the built-in WhatsApp send-eligibility baseline (subscribed, not blocked, deliverable) — never widen it.

Authorizations:
bearerAuth
Request Body schema: application/json
required
name
string [ 1 .. 200 ] characters
description
string <= 2000 characters
status
string
Enum: "draft" "scheduled"

draft, scheduled only — execution states cannot be set via the API. Omitted on create → draft.

type
string
Enum: "broadcast" "drip" "triggered"

Omitted on create → broadcast.

template_id
string <uuid>

WhatsApp template to send.

template_name
string <= 512 characters
audience_id
string <uuid>

A saved audience; takes precedence over audience_filters. Must belong to your workspace (404 otherwise).

audience_filters
object

A $where condition tree (same grammar as the contacts filter $where); validated structurally and test-compiled at write time — failures return 400 with an errors array.

custom_message
string
scheduled_at
string <date-time>

ISO 8601. A date-only value ("2026-07-01") is accepted and interpreted as a date; an unparseable value returns 400 Invalid date value for "scheduled_at": "<value>".

timezone
string <= 64 characters

Omitted → UTC.

instance_id
string <uuid>

WhatsApp instance (phone number connection) to send from.

variables
object

Template variable mappings.

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "description": "string",
  • "status": "draft",
  • "type": "broadcast",
  • "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
  • "template_name": "string",
  • "audience_id": "875075bb-caff-4035-8f5f-9fb1430dc417",
  • "audience_filters": { },
  • "custom_message": "string",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "timezone": "string",
  • "instance_id": "06587974-2dbe-4e10-8bf9-38cce0f5a366",
  • "variables": { }
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "name": "string",
  • "description": "string",
  • "status": "string",
  • "type": "broadcast",
  • "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
  • "template_name": "string",
  • "audience_baseline": "string",
  • "audience_id": "875075bb-caff-4035-8f5f-9fb1430dc417",
  • "audience_filters": { },
  • "custom_message": "string",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "timezone": "string",
  • "started_at": "2019-08-24T14:15:22Z",
  • "completed_at": "2019-08-24T14:15:22Z",
  • "total_recipients": 0,
  • "sent_count": 0,
  • "delivered_count": 0,
  • "read_count": 0,
  • "failed_count": 0,
  • "reply_count": 0,
  • "pending_retry_count": 0,
  • "instance_id": "06587974-2dbe-4e10-8bf9-38cce0f5a366",
  • "variables": { },
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Get one campaign

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "name": "string",
  • "description": "string",
  • "status": "string",
  • "type": "broadcast",
  • "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
  • "template_name": "string",
  • "audience_baseline": "string",
  • "audience_id": "875075bb-caff-4035-8f5f-9fb1430dc417",
  • "audience_filters": { },
  • "custom_message": "string",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "timezone": "string",
  • "started_at": "2019-08-24T14:15:22Z",
  • "completed_at": "2019-08-24T14:15:22Z",
  • "total_recipients": 0,
  • "sent_count": 0,
  • "delivered_count": 0,
  • "read_count": 0,
  • "failed_count": 0,
  • "reply_count": 0,
  • "pending_retry_count": 0,
  • "instance_id": "06587974-2dbe-4e10-8bf9-38cce0f5a366",
  • "variables": { },
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Update a campaign

Same fields as create, all optional; same audience validation and scheduling re-sync. The API can only set status to draft or scheduled. There is no restriction based on the campaign's current status — patching a running/completed campaign is possible and re-syncs scheduling, so take care not to accidentally re-schedule a finished campaign.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Request Body schema: application/json
required
name
string [ 1 .. 200 ] characters
description
string <= 2000 characters
status
string
Enum: "draft" "scheduled"

draft, scheduled only — execution states cannot be set via the API. Omitted on create → draft.

type
string
Enum: "broadcast" "drip" "triggered"

Omitted on create → broadcast.

template_id
string <uuid>

WhatsApp template to send.

template_name
string <= 512 characters
audience_id
string <uuid>

A saved audience; takes precedence over audience_filters. Must belong to your workspace (404 otherwise).

audience_filters
object

A $where condition tree (same grammar as the contacts filter $where); validated structurally and test-compiled at write time — failures return 400 with an errors array.

custom_message
string
scheduled_at
string <date-time>

ISO 8601. A date-only value ("2026-07-01") is accepted and interpreted as a date; an unparseable value returns 400 Invalid date value for "scheduled_at": "<value>".

timezone
string <= 64 characters

Omitted → UTC.

instance_id
string <uuid>

WhatsApp instance (phone number connection) to send from.

variables
object

Template variable mappings.

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "description": "string",
  • "status": "draft",
  • "type": "broadcast",
  • "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
  • "template_name": "string",
  • "audience_id": "875075bb-caff-4035-8f5f-9fb1430dc417",
  • "audience_filters": { },
  • "custom_message": "string",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "timezone": "string",
  • "instance_id": "06587974-2dbe-4e10-8bf9-38cce0f5a366",
  • "variables": { }
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "name": "string",
  • "description": "string",
  • "status": "string",
  • "type": "broadcast",
  • "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
  • "template_name": "string",
  • "audience_baseline": "string",
  • "audience_id": "875075bb-caff-4035-8f5f-9fb1430dc417",
  • "audience_filters": { },
  • "custom_message": "string",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "timezone": "string",
  • "started_at": "2019-08-24T14:15:22Z",
  • "completed_at": "2019-08-24T14:15:22Z",
  • "total_recipients": 0,
  • "sent_count": 0,
  • "delivered_count": 0,
  • "read_count": 0,
  • "failed_count": 0,
  • "reply_count": 0,
  • "pending_retry_count": 0,
  • "instance_id": "06587974-2dbe-4e10-8bf9-38cce0f5a366",
  • "variables": { },
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Queue a scheduled campaign for immediate execution

The campaign must have status: "scheduled" — a draft must first be PATCHed to scheduled. No request body.

Success answers 200; failures use real error statuses with the structured {"error": {"code", "message"}} envelope (404 campaign_not_found, 409 campaign_not_scheduled).

A campaign created via POST /v1/campaigns without an explicit status defaults to draft, so a naive create → execute sequence returns the 409 — set status: "scheduled" on create, or PATCH it, before executing.

Execution is queued with a per-campaign job id, so repeated execute calls while a run is queued do not enqueue duplicates. Delivery progress is reflected in the campaign's counters, which you can poll via GET /v1/campaigns/{id}.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "success": true,
  • "message": "Campaign queued for execution",
  • "jobId": "execute-1f2e3d4c-..."
}

Templates

WhatsApp message templates — read and send only; template management is in-app.

List templates

Standard list conventions. Only templates with an approved status can be delivered by WhatsApp.

Authorizations:
bearerAuth
query Parameters
filter
string
Default: "{}"
Example: filter={"lifecycle_stage":"lead"}

String-encoded JSON object. Invalid JSON → 400 "Invalid filter: must be valid JSON"; a non-object → 400 "Invalid filter: must be a JSON object". Any workspace_id in the filter is ignored and replaced with your authenticated workspace.

Grammar: exact match ({"lifecycle_stage": "lead"}; array values match any member), $where (nested condition tree, max 100 nodes, max 200 items per array value), and $gt (the only top-level comparison shortcut). Contacts additionally support $jsonb_contains, $event_attendance, and virtual consent fields. Note: the full set of $where operators is not exhaustively enumerated in the public docs — see the Getting Started guide for the documented examples.

Filter values are type-checked against the target field before the query runs — a mistyped value returns 400 Invalid filter value for "<field>": … (per field kind: not a date / not a UUID / must be one of / not a number / not a boolean). Date-only strings ("2026-01-01"), numeric strings ("125"), and "true"/"false" strings are accepted and coerced. Substring operators (contains, not_contains, starts_with, ends_with) in $where trees are rejected on non-text fields (400 Invalid filter on "<field>": substring operators (contains, starts_with, ends_with) only apply to text fields).

sort
string
Default: "-created_at"

Field name; - prefix for descending.

limit
integer [ 0 .. 500 ]
Default: 50

Hard cap 500. Non-integer or negative → 400.

offset
integer >= 0
Default: 0

Non-integer or negative → 400.

search
string

Free-text search (fields vary per resource).

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "total": 0,
  • "limit": 0,
  • "offset": 0
}

Get one template

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "string",
  • "display_name": "string",
  • "category": "string",
  • "language": "string",
  • "status": "string",
  • "header_type": "string",
  • "header_text": "string",
  • "body_text": "string",
  • "body_variables_examples": [
    ],
  • "footer_text": "string",
  • "buttons": [
    ],
  • "meta_template_id": "string",
  • "rejection_reason": "string",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Send a template message

Sends the template to a phone number. The template must belong to your workspace (404 otherwise). The recipient contact is matched by normalized phone — created automatically if it doesn't exist — and the sent message is recorded in the Inbox conversation.

The nested shapes of body_variables / header_config / button_configs are strictly validated on the request: a malformed entry (e.g. body_variables: [null], or a button without parameters) returns 400 in the standard validation shape, where message is an array of per-field strings (e.g. ["body_variables.0.text must be a string"]). Unknown properties inside these nested objects are rejected too (property <name> should not exist). Content that only the WhatsApp provider rejects still surfaces as a 502.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Request Body schema: application/json
required
to
required
string

Recipient phone, international format (e.g. +972501234567).

Array of objects

Values for the template's body placeholders. param_name matches named placeholders; otherwise values apply by position. Entries are strictly validated on the request — type and text are required, and unknown properties are rejected.

object

{ "type": "text", "variables": ["..."] } for text headers, or { "type": "media", "media_type": "image"|"video"|"document", "media_link": "https://..." } for media headers. Strictly validated on the request — unknown properties are rejected.

Array of objects

Values for dynamic buttons (e.g. dynamic-URL suffixes). The set of valid type values is not enumerated in the public docs. Entries are strictly validated on the request — parameters is required; send [] for a button with no dynamic values.

Responses

Request samples

Content type
application/json
{
  • "to": "string",
  • "body_variables": [
    ],
  • "header_config": {
    },
  • "button_configs": [
    ]
}

Response samples

Content type
application/json
{
  • "success": true,
  • "wamid": "string",
  • "message_id": "d7d9d9fd-478f-40e6-b651-49b7f19878a2",
  • "conversation_id": "cc71b11a-25cd-4c2d-9950-df2cc38e3407"
}

Pipelines

Read pipelines and their stages.

List pipelines with their stages

Returns a JSON array (no pagination envelope) of the workspace's pipelines, ordered by position, each with its ordered stages. Exactly one pipeline per workspace has is_default: true. Use this to map stage ids before creating or moving deals.

Authorizations:
bearerAuth

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Deals

Deal CRUD-minus-delete, stage moves, win/lose, idempotent upsert via external_reference.

List deals

Uses dedicated query parameters (not the generic filter) and its own pagination defaults: limit default 25 (cap 100), offset min 0. Absent or empty limit/offset values default; malformed values return 400 ("Invalid limit: must be a non-negative integer"). The UUID query parameters are validated — a malformed value returns 400 ("Invalid pipeline_id: must be a UUID"), while an empty value (?pipeline_id=) is treated as absent. Unrecognized enum filter values are still silently ignored rather than returning 400. Results are ordered newest-first. Rows include the joined contact_name/contact_phone/contact_email.

Authorizations:
bearerAuth
query Parameters
pipeline_id
string <uuid>

Exact match — take the id from GET /v1/pipelines. Malformed → 400 "Invalid pipeline_id: must be a UUID"; empty is treated as absent.

stage_id
string <uuid>

Exact match. Malformed → 400 "Invalid stage_id: must be a UUID".

status
string
Enum: "open" "won" "lost"

Any other value is silently ignored (unfiltered result).

contact_id
string <uuid>

Exact match. Malformed → 400 "Invalid contact_id: must be a UUID".

owner_user_id
string <uuid>

Exact match. Malformed → 400 "Invalid owner_user_id: must be a UUID".

external_reference
string

Exact match — look up a deal by your idempotency reference.

search
string

Case-insensitive match over deal title + contact name/phone/email.

limit
integer [ 1 .. 100 ]
Default: 25

Default 25, cap 100. Absent or empty values default; a malformed value returns 400 "Invalid limit: must be a non-negative integer".

offset
integer >= 0
Default: 0

Default 0, min 0. Absent or empty values default; a malformed value returns 400 "Invalid offset: must be a non-negative integer".

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "total": 0,
  • "limit": 0,
  • "offset": 0
}

Create a deal (idempotent upsert via `external_reference`)

Creates a deal — or, when external_reference matches an existing deal in the workspace, updates it:

  • Fields updated (only those present in the body): product_id (when a product reference resolves), title (still ignored while a product is attached), amount, currency, owner_user_id, expected_close_at, note.
  • contact_id is always re-applied from the freshly resolved contact — a repeat POST with different phone/email re-points the deal to that contact. Every repeat POST must still carry contact_id or phone/email.
  • stage_id, if present and different from the current stage, moves the deal (same ledger/automation effects as POST /v1/deals/{id}/stage).
  • status is never touched on a match; pipeline_id is ignored on a match.

Contact resolution is shared with the contacts upsert: contact_id wins; otherwise phone/email are upserted (phone match wins, email fallback). While a product is attached, the deal's title derives from the product name and any client-sent title is ignored; a missing amount defaults to the product's price.

The response is 201 in both cases and carries a top-level boolean duplicate field — false when this request created the deal, true when the external_reference matched an existing deal.

Authorizations:
bearerAuth
Request Body schema: application/json
required
contact_id
string <uuid>

Existing contact (404 if not in this workspace). Wins over phone/email.

phone
string <= 32 characters

International format preferred.

email
string <email>
name
string <= 200 characters

Used only when a new contact is created.

title
string <= 200 characters

Required unless a product is attached (then derived from the product name).

product_id
string <uuid>

Attach a product by id.

product_sku
string <= 120 characters

Attach by SKU.

product_external_id
string <= 255 characters

Attach by your external product id.

amount
number [ 0 .. 9999999999 ]

Rounded to 2 decimals. Omitted → product price if a product is attached, else 0.

currency
string <= 8 characters

Uppercased. Omitted → workspace default currency.

pipeline_id
string <uuid>

Omitted → the workspace default pipeline. Ignored on an external_reference match.

stage_id
string <uuid>

Omitted → the pipeline's first stage. On an external_reference match, a different stage moves the deal.

owner_user_id
string <uuid>

Must be a workspace agent. Omitted → the deal is unowned.

expected_close_at
string <date-time>
note
string <= 4000 characters
external_reference
string <= 255 characters

Idempotency key, unique per workspace.

Responses

Request samples

Content type
application/json
{
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "phone": "string",
  • "email": "user@example.com",
  • "name": "string",
  • "title": "string",
  • "product_id": "0d012afa-f885-4e65-aeca-37e27701e2d1",
  • "product_sku": "string",
  • "product_external_id": "string",
  • "amount": 9999999999,
  • "currency": "string",
  • "pipeline_id": "ec036e81-7903-4e4d-bbfa-ac8516341cf0",
  • "stage_id": "f38d2b9c-0a0a-4eea-91a8-e974413bb670",
  • "owner_user_id": "65139110-7c3c-4777-b692-80c218be3b9d",
  • "expected_close_at": "2019-08-24T14:15:22Z",
  • "note": "string",
  • "external_reference": "string"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "pipeline_id": "ec036e81-7903-4e4d-bbfa-ac8516341cf0",
  • "stage_id": "f38d2b9c-0a0a-4eea-91a8-e974413bb670",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "product_id": "0d012afa-f885-4e65-aeca-37e27701e2d1",
  • "owner_user_id": "65139110-7c3c-4777-b692-80c218be3b9d",
  • "title": "string",
  • "amount": 0,
  • "currency": "string",
  • "status": "open",
  • "lost_reason": "string",
  • "expected_close_at": "2019-08-24T14:15:22Z",
  • "closed_at": "2019-08-24T14:15:22Z",
  • "stage_entered_at": "2019-08-24T14:15:22Z",
  • "position": 0,
  • "note": "string",
  • "external_reference": "string",
  • "source": "string",
  • "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "contact_name": "string",
  • "contact_phone": "string",
  • "contact_email": "string",
  • "duplicate": true
}

Get one deal

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "pipeline_id": "ec036e81-7903-4e4d-bbfa-ac8516341cf0",
  • "stage_id": "f38d2b9c-0a0a-4eea-91a8-e974413bb670",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "product_id": "0d012afa-f885-4e65-aeca-37e27701e2d1",
  • "owner_user_id": "65139110-7c3c-4777-b692-80c218be3b9d",
  • "title": "string",
  • "amount": 0,
  • "currency": "string",
  • "status": "open",
  • "lost_reason": "string",
  • "expected_close_at": "2019-08-24T14:15:22Z",
  • "closed_at": "2019-08-24T14:15:22Z",
  • "stage_entered_at": "2019-08-24T14:15:22Z",
  • "position": 0,
  • "note": "string",
  • "external_reference": "string",
  • "source": "string",
  • "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "contact_name": "string",
  • "contact_phone": "string",
  • "contact_email": "string"
}

Update deal fields

Field updates do not write stage-history entries and do not fire deal automations — only the /stage and /status routes do.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Request Body schema: application/json
required
product_id
string or null <uuid>

UUID attaches/replaces (must be active unless it's the already-attached product); null detaches — the title keeps its current text.

title
string <= 200 characters

Ignored while a product is (or ends up) attached.

amount
number [ 0 .. 9999999999 ]

Rounded to 2 decimals.

currency
string <= 8 characters

Uppercased.

contact_id
string <uuid>

Re-points the deal; 404 "Contact not found" if not in this workspace.

owner_user_id
string or null <uuid>

null unassigns; a UUID must be a workspace agent (400 INVALID_DEAL_OWNER).

expected_close_at
string or null <date-time>

null clears.

note
string or null <= 4000 characters

Trimmed; empty becomes null.

Responses

Request samples

Content type
application/json
{
  • "product_id": "0d012afa-f885-4e65-aeca-37e27701e2d1",
  • "title": "string",
  • "amount": 9999999999,
  • "currency": "string",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "owner_user_id": "65139110-7c3c-4777-b692-80c218be3b9d",
  • "expected_close_at": "2019-08-24T14:15:22Z",
  • "note": "string"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "pipeline_id": "ec036e81-7903-4e4d-bbfa-ac8516341cf0",
  • "stage_id": "f38d2b9c-0a0a-4eea-91a8-e974413bb670",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "product_id": "0d012afa-f885-4e65-aeca-37e27701e2d1",
  • "owner_user_id": "65139110-7c3c-4777-b692-80c218be3b9d",
  • "title": "string",
  • "amount": 0,
  • "currency": "string",
  • "status": "open",
  • "lost_reason": "string",
  • "expected_close_at": "2019-08-24T14:15:22Z",
  • "closed_at": "2019-08-24T14:15:22Z",
  • "stage_entered_at": "2019-08-24T14:15:22Z",
  • "position": 0,
  • "note": "string",
  • "external_reference": "string",
  • "source": "string",
  • "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "contact_name": "string",
  • "contact_phone": "string",
  • "contact_email": "string"
}

Move a deal to a stage

Move a deal to any stage in the workspace — cross-pipeline moves are supported; the deal's pipeline_id follows the target stage. A move to the deal's current stage is a pure reorder (no history entry, no automations). A genuine stage change stamps stage_entered_at, records a stage-transition history entry and activity row, and fires deal-stage-changed automations.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Request Body schema: application/json
required
stage_id
required
string <uuid>

Target stage.

index
integer [ 0 .. 100000 ]

Position within the stage column, 0 = top. Omitted → top.

Responses

Request samples

Content type
application/json
{
  • "stage_id": "f38d2b9c-0a0a-4eea-91a8-e974413bb670",
  • "index": 100000
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "pipeline_id": "ec036e81-7903-4e4d-bbfa-ac8516341cf0",
  • "stage_id": "f38d2b9c-0a0a-4eea-91a8-e974413bb670",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "product_id": "0d012afa-f885-4e65-aeca-37e27701e2d1",
  • "owner_user_id": "65139110-7c3c-4777-b692-80c218be3b9d",
  • "title": "string",
  • "amount": 0,
  • "currency": "string",
  • "status": "open",
  • "lost_reason": "string",
  • "expected_close_at": "2019-08-24T14:15:22Z",
  • "closed_at": "2019-08-24T14:15:22Z",
  • "stage_entered_at": "2019-08-24T14:15:22Z",
  • "position": 0,
  • "note": "string",
  • "external_reference": "string",
  • "source": "string",
  • "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "contact_name": "string",
  • "contact_phone": "string",
  • "contact_email": "string"
}

Set status: open / won / lost

Closing (won/lost) stamps closed_at and keeps the deal on its last stage. Reopening (open) clears closed_at and lost_reason. won/lost fire the corresponding deal automations (reopen fires none). If the deal already has the requested status, the current row is returned unchanged (no history entry, no automations).

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Request Body schema: application/json
required
status
required
string
Enum: "open" "won" "lost"

open reopens a closed deal.

lost_reason
string <= 1000 characters

Stored only when status is lost; cleared otherwise.

Responses

Request samples

Content type
application/json
{
  • "status": "open",
  • "lost_reason": "string"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "pipeline_id": "ec036e81-7903-4e4d-bbfa-ac8516341cf0",
  • "stage_id": "f38d2b9c-0a0a-4eea-91a8-e974413bb670",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "product_id": "0d012afa-f885-4e65-aeca-37e27701e2d1",
  • "owner_user_id": "65139110-7c3c-4777-b692-80c218be3b9d",
  • "title": "string",
  • "amount": 0,
  • "currency": "string",
  • "status": "open",
  • "lost_reason": "string",
  • "expected_close_at": "2019-08-24T14:15:22Z",
  • "closed_at": "2019-08-24T14:15:22Z",
  • "stage_entered_at": "2019-08-24T14:15:22Z",
  • "position": 0,
  • "note": "string",
  • "external_reference": "string",
  • "source": "string",
  • "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "contact_name": "string",
  • "contact_phone": "string",
  • "contact_email": "string"
}

Products

The workspace product catalog shared by deals and payments — list, get, create (idempotent upsert via external_id), update. No DELETE; deactivate with is_active: false.

List products

Standard list envelope, newest first. Filters combine (AND). q is a literal substring match on name or description (case-insensitive; %/_ are not wildcards); sku and external_id are exact-match lookups.

Authorizations:
bearerAuth
query Parameters
q
string

Substring match on name or description.

sku
string

Exact-match lookup by SKU.

external_id
string

Exact-match lookup by external id.

is_active
boolean

true | false; any other value returns 400.

limit
integer [ 0 .. 500 ]
Default: 50

Hard cap 500. Non-integer or negative → 400.

offset
integer >= 0
Default: 0

Non-integer or negative → 400.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "total": 0,
  • "limit": 0,
  • "offset": 0
}

Create a product (idempotent upsert via external_id)

When external_id matches an existing product, that product's fields are updated instead of a new one being created and the response carries duplicate: true (201 for both outcomes). sku and external_id are each per-workspace-unique — a clash with a different product answers 409 product_conflict. vat_mode + vat_rate travel as one both-or-neither pair (400 when only one leg is sent).

Authorizations:
bearerAuth
Request Body schema: application/json
required
name
required
string [ 1 .. 200 ] characters
sku
string or null <= 100 characters

Per-workspace-unique product code (human-facing).

external_id
string or null <= 200 characters

Per-workspace-unique id of this product in your system — the POST idempotency key (a repeat POST with the same value updates that product instead of creating a duplicate).

description
string or null <= 2000 characters
price
number or null >= 0

Default price in the workspace payment currency. null = dynamic pricing (deals fall back to an explicit amount).

vat_mode
string or null
Enum: "inclusive" "exclusive" null

Per-product VAT override. Travels with vat_rate as one both-or-neither pair; send both null to clear the override.

vat_rate
number or null [ 0 .. 100 ]

VAT percent (0–100, max 2 decimals); paired with vat_mode.

is_active
boolean

Inactive products stay on existing deals/payments but cannot be attached to new ones.

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "sku": "string",
  • "external_id": "string",
  • "description": "string",
  • "price": 0,
  • "vat_mode": "inclusive",
  • "vat_rate": 100,
  • "is_active": true
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "name": "string",
  • "sku": "string",
  • "external_id": "string",
  • "description": "string",
  • "price": 0,
  • "vat_mode": "inclusive",
  • "vat_rate": 0,
  • "is_active": true,
  • "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "duplicate": true
}

Get a product

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "name": "string",
  • "sku": "string",
  • "external_id": "string",
  • "description": "string",
  • "price": 0,
  • "vat_mode": "inclusive",
  • "vat_rate": 0,
  • "is_active": true,
  • "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Update a product

Partial update — only the fields present in the body change (a field you don't send is never nulled). There is no DELETE: deactivate with {"is_active": false} so records that reference the product stay intact.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Request Body schema: application/json
required
name
string [ 1 .. 200 ] characters
sku
string or null <= 100 characters

Per-workspace-unique product code (human-facing).

external_id
string or null <= 200 characters

Per-workspace-unique id of this product in your system — the POST idempotency key (a repeat POST with the same value updates that product instead of creating a duplicate).

description
string or null <= 2000 characters
price
number or null >= 0

Default price in the workspace payment currency. null = dynamic pricing (deals fall back to an explicit amount).

vat_mode
string or null
Enum: "inclusive" "exclusive" null

Per-product VAT override. Travels with vat_rate as one both-or-neither pair; send both null to clear the override.

vat_rate
number or null [ 0 .. 100 ]

VAT percent (0–100, max 2 decimals); paired with vat_mode.

is_active
boolean

Inactive products stay on existing deals/payments but cannot be attached to new ones.

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "sku": "string",
  • "external_id": "string",
  • "description": "string",
  • "price": 0,
  • "vat_mode": "inclusive",
  • "vat_rate": 100,
  • "is_active": true
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "name": "string",
  • "sku": "string",
  • "external_id": "string",
  • "description": "string",
  • "price": 0,
  • "vat_mode": "inclusive",
  • "vat_rate": 0,
  • "is_active": true,
  • "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Payments

One-time / recurring / installment payments, entries, refunds.

List payments

Uses dedicated query parameters and its own pagination defaults (limit default 25, cap 100). Absent or empty limit/offset values default; malformed values return 400 ("Invalid limit: must be a non-negative integer"). Unrecognized enum values are still silently ignored. Ordered by purchase_date descending. Rows include joined contact_name/contact_phone/contact_email. List rows do not include entries.

Authorizations:
bearerAuth
query Parameters
type
string
Enum: "one_time" "recurring" "installments"

Other values are silently ignored.

status
string
Enum: "active" "completed" "cancelled"

Arrangement status: other values silently ignored.

search
string

Case-insensitive match over payment title + contact name/phone/email.

limit
integer [ 1 .. 100 ]
Default: 25

Default 25, cap 100. Absent or empty values default; a malformed value returns 400 "Invalid limit: must be a non-negative integer".

offset
integer >= 0
Default: 0

Default 0, min 0. Absent or empty values default; a malformed value returns 400 "Invalid offset: must be a non-negative integer".

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "total": 0,
  • "limit": 0,
  • "offset": 0
}

Create a payment (idempotent upsert via `external_reference`)

Creates a payment — or, when external_reference matches an existing payment, updates it. Only mutable fields are applied on a match:

  • Always (when present): product_id, title (subject to the product title lock), note, method, metadata (the provided object replaces the stored one — omit it to keep it).
  • Only when the existing payment is one_time: amount, status.
  • Only when it is recurring: auto_generate, recurring_end_at, recurring_max_occurrences, and the vat_mode+vat_rate pair (a full pair re-prices the plan — future cycles are charged and documented with the new posture).
  • Never restructured on a match: type, interval, installment_count, purchase_date — and, unlike deals, the contact is NOT re-pointed on a match.

Entry creation per type: one_time — one entry with the given status (the header's arrangement_status mirrors it); recurring — the first cycle is recorded now when record_first_payment is true (default), further cycles record automatically when auto_generate is on; installmentsinstallment_count monthly entries starting at purchase_date, recognized as revenue immediately.

Contact resolution is identical to deals (409 CONTACT_MERGE_REQUIRED on identity conflict). Unlike deals, amount is always required — the product price does not substitute for it. The response is 201 in both cases and carries a top-level boolean duplicate field — false when this request created the payment, true when the external_reference matched an existing payment.

Authorizations:
bearerAuth
Request Body schema: application/json
required
contact_id
string <uuid>

Existing contact (404 if not in this workspace). Wins over phone/email.

phone
string <= 32 characters
email
string <email>
name
string <= 200 characters

Used only for a newly created contact.

type
required
string
Enum: "one_time" "recurring" "installments"
amount
required
number [ 0 .. 9999999999 ]

one_time: the charge amount; recurring: amount per cycle; installments: the total deal amount. Always required — the product price does not substitute for it.

product_id
string <uuid>
product_sku
string <= 120 characters
product_external_id
string <= 255 characters
title
string <= 200 characters

Ignored while a product is attached (title derives from the product name).

note
string <= 1000 characters
method
string
Enum: "cash" "card" "bank_transfer" "other"
currency
string <= 3 characters

Uppercased. Omitted → workspace default currency.

purchase_date
string

ISO 8601. A date-only value ("2026-07-14") is interpreted as start of that day in the workspace timezone. Omitted → now.

status
string
Enum: "pending" "completed" "failed" "refunded"

One-time only. Omitted → completed.

interval
string
Enum: "weekly" "monthly" "quarterly" "yearly"

Recurring only. Omitted → monthly.

auto_generate
boolean

Recurring only: automatically record each cycle when due.

record_first_payment
boolean
Default: true

Recurring only: record the first cycle immediately.

recurring_end_at
string

Recurring only; ISO 8601 — a date-only value means end of that day in the workspace timezone; must be after the purchase date.

recurring_max_occurrences
integer >= 1

Recurring only — total charge cycles ever.

vat_mode
string
Enum: "inclusive" "exclusive"

Recurring plans only, always together with vat_rate (a full pair — on other types the pair returns 400 "vatMode/vatRate apply to recurring plans only"; a lone leg returns 400). The VAT posture the plan's cycles are charged and documented with: inclusive (VAT included in the amount) or exclusive (the amount is net; VAT is added on top; exclusive + rate 0 = VAT-exempt). Omitted → the plan stores the attached product's VAT pair, else the workspace default. On an external_reference match a provided pair re-prices the plan.

vat_rate
number [ 0 .. 100 ]

Recurring only, always together with vat_mode: VAT rate percent (0–100, at most 2 decimal places).

installment_count
integer [ 2 .. 360 ]

Required when type is installments. Over the ceiling → 400 "installmentCount must be at most 360".

external_reference
string <= 255 characters

Idempotency key, unique per workspace.

metadata
object

Free-form JSON stored on the payment — max 2048 bytes serialized (400 "metadata exceeds 2048 bytes serialized" over the cap). Returned on reads and surfaced to payment automations. On an external_reference match the provided object replaces the stored one (omit to keep it).

Responses

Request samples

Content type
application/json
{
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "phone": "string",
  • "email": "user@example.com",
  • "name": "string",
  • "type": "one_time",
  • "amount": 9999999999,
  • "product_id": "0d012afa-f885-4e65-aeca-37e27701e2d1",
  • "product_sku": "string",
  • "product_external_id": "string",
  • "title": "string",
  • "note": "string",
  • "method": "cash",
  • "currency": "str",
  • "purchase_date": "string",
  • "status": "pending",
  • "interval": "weekly",
  • "auto_generate": true,
  • "record_first_payment": true,
  • "recurring_end_at": "string",
  • "recurring_max_occurrences": 1,
  • "vat_mode": "inclusive",
  • "vat_rate": 100,
  • "installment_count": 2,
  • "external_reference": "string",
  • "metadata": { }
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "product_id": "0d012afa-f885-4e65-aeca-37e27701e2d1",
  • "type": "one_time",
  • "title": "string",
  • "note": "string",
  • "currency": "string",
  • "total_amount": 0,
  • "arrangement_status": "active",
  • "method": "cash",
  • "purchase_date": "2019-08-24T14:15:22Z",
  • "recurring_interval": "weekly",
  • "recurring_next_due_at": "2019-08-24T14:15:22Z",
  • "recurring_auto_generate": true,
  • "recurring_cancelled_at": "2019-08-24T14:15:22Z",
  • "recurring_end_at": "2019-08-24T14:15:22Z",
  • "recurring_max_occurrences": 0,
  • "recurring_payment_method_id": "40505daa-dc68-4f54-9b57-2fe1c01c228d",
  • "recurring_last_attempt_at": "2019-08-24T14:15:22Z",
  • "recurring_failure_count": 0,
  • "recurring_paused_at": "2019-08-24T14:15:22Z",
  • "recurring_next_retry_at": "2019-08-24T14:15:22Z",
  • "recurring_dunning_started_at": "2019-08-24T14:15:22Z",
  • "vat_mode": "inclusive",
  • "vat_rate": 0,
  • "installment_count": 0,
  • "external_reference": "string",
  • "source": "string",
  • "metadata": { },
  • "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "contact_name": "string",
  • "contact_phone": "string",
  • "contact_email": "string",
  • "entries": [
    ],
  • "duplicate": true
}

Get a payment with its entries

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "product_id": "0d012afa-f885-4e65-aeca-37e27701e2d1",
  • "type": "one_time",
  • "title": "string",
  • "note": "string",
  • "currency": "string",
  • "total_amount": 0,
  • "arrangement_status": "active",
  • "method": "cash",
  • "purchase_date": "2019-08-24T14:15:22Z",
  • "recurring_interval": "weekly",
  • "recurring_next_due_at": "2019-08-24T14:15:22Z",
  • "recurring_auto_generate": true,
  • "recurring_cancelled_at": "2019-08-24T14:15:22Z",
  • "recurring_end_at": "2019-08-24T14:15:22Z",
  • "recurring_max_occurrences": 0,
  • "recurring_payment_method_id": "40505daa-dc68-4f54-9b57-2fe1c01c228d",
  • "recurring_last_attempt_at": "2019-08-24T14:15:22Z",
  • "recurring_failure_count": 0,
  • "recurring_paused_at": "2019-08-24T14:15:22Z",
  • "recurring_next_retry_at": "2019-08-24T14:15:22Z",
  • "recurring_dunning_started_at": "2019-08-24T14:15:22Z",
  • "vat_mode": "inclusive",
  • "vat_rate": 0,
  • "installment_count": 0,
  • "external_reference": "string",
  • "source": "string",
  • "metadata": { },
  • "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "contact_name": "string",
  • "contact_phone": "string",
  • "contact_email": "string",
  • "entries": [
    ]
}

Update a payment

A one-time amount/status change flows into the payment's single entry and re-maps the header's arrangement_status. Shortening a recurring plan's end conditions may auto-complete it; extending them never silently reactivates a completed plan. A one-time status change fires the matching automation after the update.

A full vat_mode+vat_rate pair replaces a recurring plan's stored VAT posture (future cycles are charged and documented with the new pair) — lone legs and nulls are rejected, and on non-recurring payments the pair returns 400. metadata replaces the stored object (null clears it; omit to keep it).

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Request Body schema: application/json
required
product_id
string or null <uuid>

Attach/replace (must be active unless already attached) or detach (null); title derives from the product while attached.

title
string <= 200 characters

Ignored while a product is attached.

note
string <= 1000 characters
method
string
Enum: "cash" "card" "bank_transfer" "other"
amount
number [ 0 .. 9999999999 ]

One-time only (silently ignored otherwise).

status
string
Enum: "pending" "completed" "failed" "refunded"

One-time only.

auto_generate
boolean

Recurring only.

recurring_end_at
string or null

Recurring only; null clears; must be after the purchase date.

recurring_max_occurrences
integer or null >= 1

Recurring only; null clears.

vat_mode
string
Enum: "inclusive" "exclusive"

Recurring only: replace the plan's stored VAT pair — always together with vat_rate. Unlike the other type-restricted patch fields this is NOT silently ignored on other types (400 "vatMode/vatRate apply to recurring plans only"), and lone legs / nulls are rejected (400 "vatMode and vatRate must be provided together") — the stored pair is a complete value. Future cycles are charged and documented with the new pair. Omit both to leave the pair as-is.

vat_rate
number [ 0 .. 100 ]

Recurring only, always together with vat_mode: VAT rate percent (0–100, at most 2 decimal places).

metadata
object or null

Replace the payment's free-form metadata object (max 2048 bytes serialized), or null to clear it. Omit to leave it as-is.

Responses

Request samples

Content type
application/json
{
  • "product_id": "0d012afa-f885-4e65-aeca-37e27701e2d1",
  • "title": "string",
  • "note": "string",
  • "method": "cash",
  • "amount": 9999999999,
  • "status": "pending",
  • "auto_generate": true,
  • "recurring_end_at": "string",
  • "recurring_max_occurrences": 1,
  • "vat_mode": "inclusive",
  • "vat_rate": 100,
  • "metadata": { }
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "product_id": "0d012afa-f885-4e65-aeca-37e27701e2d1",
  • "type": "one_time",
  • "title": "string",
  • "note": "string",
  • "currency": "string",
  • "total_amount": 0,
  • "arrangement_status": "active",
  • "method": "cash",
  • "purchase_date": "2019-08-24T14:15:22Z",
  • "recurring_interval": "weekly",
  • "recurring_next_due_at": "2019-08-24T14:15:22Z",
  • "recurring_auto_generate": true,
  • "recurring_cancelled_at": "2019-08-24T14:15:22Z",
  • "recurring_end_at": "2019-08-24T14:15:22Z",
  • "recurring_max_occurrences": 0,
  • "recurring_payment_method_id": "40505daa-dc68-4f54-9b57-2fe1c01c228d",
  • "recurring_last_attempt_at": "2019-08-24T14:15:22Z",
  • "recurring_failure_count": 0,
  • "recurring_paused_at": "2019-08-24T14:15:22Z",
  • "recurring_next_retry_at": "2019-08-24T14:15:22Z",
  • "recurring_dunning_started_at": "2019-08-24T14:15:22Z",
  • "vat_mode": "inclusive",
  • "vat_rate": 0,
  • "installment_count": 0,
  • "external_reference": "string",
  • "source": "string",
  • "metadata": { },
  • "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "contact_name": "string",
  • "contact_phone": "string",
  • "contact_email": "string",
  • "entries": [
    ]
}

Cancel a recurring plan

Cancels a recurring plan. No request body. Already-recorded entries are untouched.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "product_id": "0d012afa-f885-4e65-aeca-37e27701e2d1",
  • "type": "one_time",
  • "title": "string",
  • "note": "string",
  • "currency": "string",
  • "total_amount": 0,
  • "arrangement_status": "active",
  • "method": "cash",
  • "purchase_date": "2019-08-24T14:15:22Z",
  • "recurring_interval": "weekly",
  • "recurring_next_due_at": "2019-08-24T14:15:22Z",
  • "recurring_auto_generate": true,
  • "recurring_cancelled_at": "2019-08-24T14:15:22Z",
  • "recurring_end_at": "2019-08-24T14:15:22Z",
  • "recurring_max_occurrences": 0,
  • "recurring_payment_method_id": "40505daa-dc68-4f54-9b57-2fe1c01c228d",
  • "recurring_last_attempt_at": "2019-08-24T14:15:22Z",
  • "recurring_failure_count": 0,
  • "recurring_paused_at": "2019-08-24T14:15:22Z",
  • "recurring_next_retry_at": "2019-08-24T14:15:22Z",
  • "recurring_dunning_started_at": "2019-08-24T14:15:22Z",
  • "vat_mode": "inclusive",
  • "vat_rate": 0,
  • "installment_count": 0,
  • "external_reference": "string",
  • "source": "string",
  • "metadata": { },
  • "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "contact_name": "string",
  • "contact_phone": "string",
  • "contact_email": "string",
  • "entries": [
    ]
}

Set one entry's status

Marking completed stamps paid_at and recognizes the revenue. A one-time header mirrors its entry's status; recurring/installment headers are not changed by marking one cycle. Status-change automations fire as on PATCH (payment-recorded only for a newly recognized completion).

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

entryId
required
string <uuid>

The entry to mark; must belong to the payment in the URL.

Request Body schema: application/json
required
status
required
string
Enum: "pending" "completed" "failed" "refunded"

Responses

Request samples

Content type
application/json
{
  • "status": "pending"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "product_id": "0d012afa-f885-4e65-aeca-37e27701e2d1",
  • "type": "one_time",
  • "title": "string",
  • "note": "string",
  • "currency": "string",
  • "total_amount": 0,
  • "arrangement_status": "active",
  • "method": "cash",
  • "purchase_date": "2019-08-24T14:15:22Z",
  • "recurring_interval": "weekly",
  • "recurring_next_due_at": "2019-08-24T14:15:22Z",
  • "recurring_auto_generate": true,
  • "recurring_cancelled_at": "2019-08-24T14:15:22Z",
  • "recurring_end_at": "2019-08-24T14:15:22Z",
  • "recurring_max_occurrences": 0,
  • "recurring_payment_method_id": "40505daa-dc68-4f54-9b57-2fe1c01c228d",
  • "recurring_last_attempt_at": "2019-08-24T14:15:22Z",
  • "recurring_failure_count": 0,
  • "recurring_paused_at": "2019-08-24T14:15:22Z",
  • "recurring_next_retry_at": "2019-08-24T14:15:22Z",
  • "recurring_dunning_started_at": "2019-08-24T14:15:22Z",
  • "vat_mode": "inclusive",
  • "vat_rate": 0,
  • "installment_count": 0,
  • "external_reference": "string",
  • "source": "string",
  • "metadata": { },
  • "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "contact_name": "string",
  • "contact_phone": "string",
  • "contact_email": "string",
  • "entries": [
    ]
}

Record a refund against a charge

Records a refund entry against a completed charge. Multiple partial refunds against one charge are supported, up to its recognized value; refunds are race-safe — concurrent refund requests cannot over-refund a charge. A fully refunded one-time payment's header becomes arrangement_status: "cancelled"; partial refunds and multi-entry deals keep their status. Fires the payment-refunded automation with the refunded amount.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Request Body schema: application/json
required
entry_id
string <uuid>

The charge entry to refund. May be omitted only when the payment has exactly one charge.

amount
number ( 0 .. 9999999999 ]

Partial refund amount. Omitted → the full remaining refundable balance.

note
string <= 1000 characters

Stored on the refund entry.

Responses

Request samples

Content type
application/json
{
  • "entry_id": "31513983-613b-4481-b270-ae929f358bae",
  • "amount": 9999999999,
  • "note": "string"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "product_id": "0d012afa-f885-4e65-aeca-37e27701e2d1",
  • "type": "one_time",
  • "title": "string",
  • "note": "string",
  • "currency": "string",
  • "total_amount": 0,
  • "arrangement_status": "active",
  • "method": "cash",
  • "purchase_date": "2019-08-24T14:15:22Z",
  • "recurring_interval": "weekly",
  • "recurring_next_due_at": "2019-08-24T14:15:22Z",
  • "recurring_auto_generate": true,
  • "recurring_cancelled_at": "2019-08-24T14:15:22Z",
  • "recurring_end_at": "2019-08-24T14:15:22Z",
  • "recurring_max_occurrences": 0,
  • "recurring_payment_method_id": "40505daa-dc68-4f54-9b57-2fe1c01c228d",
  • "recurring_last_attempt_at": "2019-08-24T14:15:22Z",
  • "recurring_failure_count": 0,
  • "recurring_paused_at": "2019-08-24T14:15:22Z",
  • "recurring_next_retry_at": "2019-08-24T14:15:22Z",
  • "recurring_dunning_started_at": "2019-08-24T14:15:22Z",
  • "vat_mode": "inclusive",
  • "vat_rate": 0,
  • "installment_count": 0,
  • "external_reference": "string",
  • "source": "string",
  • "metadata": { },
  • "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "contact_name": "string",
  • "contact_phone": "string",
  • "contact_email": "string",
  • "entries": [
    ]
}

Payment requests

Hosted pay-by-link requests collected through the workspace's own connected payment provider (Cardcom / Sumit). Mint, list, get, cancel — no idempotency key; a repeat POST mints a second link. Requires the workspace_payments feature.

List payment requests

Uses dedicated query parameters and the deals/payments pagination family (limit default 25, cap 100; absent or empty values default; malformed values return 400 "Invalid limit: must be a non-negative integer"). Unlike deals/payments, an unknown status value returns 400 rather than being silently ignored. Ordered by created_at descending. Rows include joined contact_name/contact_phone/contact_email and a computed refunded_total.

Authorizations:
bearerAuth
query Parameters
status
string
Enum: "pending" "paid" "expired" "cancelled"

Unknown values return 400.

contact_id
string <uuid>

Malformed (non-UUID) values return 400; empty values are treated as absent.

deal_id
string <uuid>

Malformed (non-UUID) values return 400; empty values are treated as absent.

limit
integer [ 1 .. 100 ]
Default: 25

Default 25, cap 100. Absent or empty values default; a malformed value returns 400 "Invalid limit: must be a non-negative integer".

offset
integer >= 0
Default: 0

Default 0, min 0. Absent or empty values default; a malformed value returns 400 "Invalid offset: must be a non-negative integer".

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "total": 0,
  • "limit": 0,
  • "offset": 0
}

Create a payment request (mint a pay-link)

Mints a hosted-checkout pay-link through the workspace's connected payment provider (Cardcom / Sumit) and returns the row with its shareable pay_url. Requires a connected provider — otherwise 400 with error_code: "NO_PAYMENT_PROVIDER".

NOT idempotent — there is no idempotency key on this resource. A repeat POST mints a second, independently payable link; cancel extras via POST /v1/payment-requests/{id}/cancel. Do not blindly retry this call after a network failure without first checking GET /v1/payment-requests for the link you may have already minted.

The payer contact resolves like payments/deals: contact_id wins; otherwise phone/email are upserted like POST /v1/contacts (same 409 CONTACT_MERGE_REQUIRED behavior); a deal_id alone also works — the deal's contact pays. None of the four → 400.

Expiry is clamped server-side to at most 72 hours from now (1 hour for test-mode requests); reminder emails, payment verification, tax-document auto-issue, and the payer receipt behave exactly as for in-app-minted links. The response also carries checkout_url/checkout_error: a provider failure at mint time leaves the row pending without a checkout session (checkout_error set) — the public pay page lazily re-creates the session when opened, so the link still works once the provider recovers.

Authorizations:
bearerAuth
Request Body schema: application/json
required
contact_id
string <uuid>

Existing contact (the payer). Wins over phone/email.

phone
string <= 32 characters

Contact phone (E.164). A matching contact is used, or created.

email
string <email>

Contact email. A matching contact is used, or created.

name
string <= 200 characters

Used only when a new contact is created.

deal_id
string <uuid>

Deal to bind the request to. When no contact is given, the deal's contact is the payer.

amount
required
number [ 0.01 .. 9999999999.99 ]

Amount to collect, in major units (at most 2 decimal places).

currency
string
Enum: "ILS" "USD" "EUR" "GBP"

Omitted → the workspace payment currency.

title
string <= 200 characters

Payer-facing charge title.

note
string <= 2000 characters
max_installments
integer [ 1 .. 36 ]

Max card installments offered on the hosted page (1–36).

document_kind
string
Enum: "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"

Tax-document kind to auto-issue on payment; omitted → the provider/account default.

auto_issue_document
boolean
Default: true

Auto-issue an Israeli tax document on successful charge.

expires_at
string <date-time>

Link expiry (ISO 8601). Clamped server-side to at most 72 hours from now (1 hour for test-mode requests); omitted → the maximum.

test_mode
boolean

Authorise-only test run — rejected (400) when the connected provider has no test mode. Test requests never record real money.

reminders_enabled
boolean

Pre-expiry reminder emails for this request; omitted → the workspace default.

offer_card_save
boolean

Offer the payer a save-my-card checkbox on the pay page — honored only when the connected provider supports card capture at checkout.

vat_mode
string
Enum: "inclusive" "exclusive"

Per-request VAT override — mode and rate travel as a PAIR (each required when the other is present; a lone leg returns 400). Omitted → the workspace payments default. VAT-exempt = exclusive + rate 0.

vat_rate
number [ 0 .. 100 ]

VAT rate percent (0–100, at most 2 decimal places); see vat_mode — the pair travels together.

Responses

Request samples

Content type
application/json
{
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "phone": "string",
  • "email": "user@example.com",
  • "name": "string",
  • "deal_id": "2fa5b64f-5b72-4263-958e-07b76f41abe3",
  • "amount": 0.01,
  • "currency": "ILS",
  • "title": "string",
  • "note": "string",
  • "max_installments": 1,
  • "document_kind": "tax_invoice",
  • "auto_issue_document": true,
  • "expires_at": "2019-08-24T14:15:22Z",
  • "test_mode": true,
  • "reminders_enabled": true,
  • "offer_card_save": true,
  • "vat_mode": "inclusive",
  • "vat_rate": 100
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "deal_id": "2fa5b64f-5b72-4263-958e-07b76f41abe3",
  • "provider": "cardcom",
  • "status": "pending",
  • "charge_kind": "checkout",
  • "amount": 0,
  • "currency": "ILS",
  • "title": "string",
  • "note": "string",
  • "max_installments": 0,
  • "auto_issue_document": true,
  • "document_kind": "string",
  • "public_token": "string",
  • "provider_checkout_ref": "string",
  • "provider_payment_ref": "string",
  • "provider_customer_ref": "string",
  • "test_mode": true,
  • "vat_mode": "inclusive",
  • "vat_rate": 0,
  • "reminders_enabled": true,
  • "reminder_count": 0,
  • "last_reminder_at": "2019-08-24T14:15:22Z",
  • "expires_at": "2019-08-24T14:15:22Z",
  • "paid_at": "2019-08-24T14:15:22Z",
  • "cancelled_at": "2019-08-24T14:15:22Z",
  • "contact_payment_id": "b0059909-e2a0-40f6-be35-1d854b970361",
  • "metadata": { },
  • "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "pay_url": "string",
  • "document": { },
  • "contact_name": "string",
  • "contact_phone": "string",
  • "contact_email": "string",
  • "refunded_total": 0,
  • "checkout_url": "string",
  • "checkout_error": "string"
}

Get a payment request

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "deal_id": "2fa5b64f-5b72-4263-958e-07b76f41abe3",
  • "provider": "cardcom",
  • "status": "pending",
  • "charge_kind": "checkout",
  • "amount": 0,
  • "currency": "ILS",
  • "title": "string",
  • "note": "string",
  • "max_installments": 0,
  • "auto_issue_document": true,
  • "document_kind": "string",
  • "public_token": "string",
  • "provider_checkout_ref": "string",
  • "provider_payment_ref": "string",
  • "provider_customer_ref": "string",
  • "test_mode": true,
  • "vat_mode": "inclusive",
  • "vat_rate": 0,
  • "reminders_enabled": true,
  • "reminder_count": 0,
  • "last_reminder_at": "2019-08-24T14:15:22Z",
  • "expires_at": "2019-08-24T14:15:22Z",
  • "paid_at": "2019-08-24T14:15:22Z",
  • "cancelled_at": "2019-08-24T14:15:22Z",
  • "contact_payment_id": "b0059909-e2a0-40f6-be35-1d854b970361",
  • "metadata": { },
  • "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "pay_url": "string",
  • "document": { },
  • "contact_name": "string",
  • "contact_phone": "string",
  • "contact_email": "string",
  • "refunded_total": 0
}

Cancel a pending payment request

Withdraws the pay-link — the hosted page stops accepting payment. No request body. Pending requests only (the cancel is a compare-and-set on status): already paid/expired/cancelled rows answer 409, and direct saved-card charge rows answer 409 TOKEN_REQUEST_NOT_CANCELLABLE. A payer already on the hosted page can still complete — such late completions are verified and recorded (the row resurrects to paid) rather than dropping the money.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "deal_id": "2fa5b64f-5b72-4263-958e-07b76f41abe3",
  • "provider": "cardcom",
  • "status": "pending",
  • "charge_kind": "checkout",
  • "amount": 0,
  • "currency": "ILS",
  • "title": "string",
  • "note": "string",
  • "max_installments": 0,
  • "auto_issue_document": true,
  • "document_kind": "string",
  • "public_token": "string",
  • "provider_checkout_ref": "string",
  • "provider_payment_ref": "string",
  • "provider_customer_ref": "string",
  • "test_mode": true,
  • "vat_mode": "inclusive",
  • "vat_rate": 0,
  • "reminders_enabled": true,
  • "reminder_count": 0,
  • "last_reminder_at": "2019-08-24T14:15:22Z",
  • "expires_at": "2019-08-24T14:15:22Z",
  • "paid_at": "2019-08-24T14:15:22Z",
  • "cancelled_at": "2019-08-24T14:15:22Z",
  • "contact_payment_id": "b0059909-e2a0-40f6-be35-1d854b970361",
  • "metadata": { },
  • "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "pay_url": "string",
  • "document": { },
  • "contact_name": "string",
  • "contact_phone": "string",
  • "contact_email": "string",
  • "refunded_total": 0
}

Contact documents

Read-only listing of a contact's financial documents (invoices, receipts, credit documents), aggregated from stored document pointers with an opt-in live provider lookup. Requires the payments feature.

List a contact's financial documents

Read-only listing of the 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). Requires the payments plan feature (the same gate as /v1/payments*), in addition to API access.

Responds { documents, live }. Each document carries kind (canonical type, or null with the provider's rawType), isCredit, provider, documentId, number, url, date, amount, currency, origin, and its sources. A document's url may be null (legacy number-only rows) — always check before opening.

By default only stored document pointers are returned. Pass ?live=true to additionally query the connected payment provider for a live document listing and merge it in (bounded to ~2.5 s; a timeout or provider failure degrades to the stored listing, reported in live.error — never an error response). With no connected/entitled provider the live lookup degrades to an empty listing.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

query Parameters
live
string
Enum: "true" "false"

true | false (default false). Any other value returns 400 ("Invalid live: must be true or false").

Responses

Response samples

Content type
application/json
{
  • "documents": [
    ],
  • "live": {
    }
}

Orders

E-commerce-grade orders on contacts — idempotent create via external_reference, refund ledger, mark-paid, cancel. No update or delete routes.

List orders

Uses dedicated query parameters (no generic filter) and the deals/payments pagination defaults (limit default 25, cap 100) — with one deliberate difference: paging on this route never returns 400. Malformed or zero limit/offset values silently default (25 / 0) and out-of-range values are clamped. Unrecognized status/source values are silently ignored (unfiltered result). Ordered by placed_at descending (not created_at). Rows include the joined contact_name/contact_phone/contact_email and omit items/refunds — fetch a single order to read them. There is no search parameter on this route.

Authorizations:
bearerAuth
query Parameters
status
string
Enum: "pending" "paid" "partially_paid" "refunded" "partially_refunded" "voided"

Financial status. Any other value is silently ignored (unfiltered result).

contact_id
string <uuid>

Exact match. Malformed → 400 "contact_id must be a UUID" (note the wording differs from deals/payments); empty (?contact_id=) is treated as absent.

source
string

Exact match — manual, api, or automation; orders synced from a connected store carry the store platform name.

store_connection_id
string <uuid>

Exact match on the store-sync provenance field — orders synced from a connected store. Malformed → 400 "store_connection_id must be a UUID".

external_reference
string

Exact match — look up an order by your idempotency reference.

placed_from
string <date-time>

Orders placed at/after this instant. Unparseable → 400 "placed_from is not a valid date".

placed_to
string <date-time>

Orders placed at/before this instant. Unparseable → 400 "placed_to is not a valid date".

limit
integer [ 1 .. 100 ]
Default: 25

Default 25, cap 100, floor 1. Unlike GET /v1/deals/GET /v1/payments, paging here never returns 400 — absent, empty, malformed, or zero values silently default to 25, and out-of-range values are clamped into 1–100.

offset
integer >= 0
Default: 0

Default 0, floor 0. Absent, empty, or malformed values silently default to 0; negative values are clamped to 0 — never a 400.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "total": 0,
  • "limit": 0,
  • "offset": 0
}

Create an order (idempotent upsert via `external_reference`)

Creates an order — or, when external_reference matches an existing order in the workspace, updates its mutable fields (race-safe: a concurrent create losing the uniqueness race retries as an update against the winner). On a match:

  • Always updated (when present in the body): note, coupon_codes, placed_at, deal_id.
  • Money fields (items, currency, discount_total, shipping_total, tax_total) apply only while the order is still pending — once paid, money is locked and repeat POSTs silently skip these fields (corrections flow through refunds/cancel).
  • financial_status is never changed on a match — an exact replay of a created-as-paid POST is a clean no-op, and a repeat POST can never resurrect a cancelled order. Status moves ride the mark-paid/cancel/refund endpoints.
  • The contact is never re-pointed on a match (unlike deals). Contact resolution still runs first, so a repeat POST must still carry contact_id or phone/email (and phone/email upsert side effects still apply to the contact itself).

Contact resolution is shared with the contacts upsert: contact_id wins; otherwise phone/email are upserted (phone match wins, email fallback). Line totals are server-computed: line_total = round2(quantity × unit_price × (1 − discount_percent/100)); subtotal = the sum of rounded line totals; total = max(0, subtotaldiscount_total + shipping_total + tax_total). Rounding is 2-decimal half-up.

The response is 201 in both cases with the full order (items + refunds + joined contact identity). Unlike the other idempotent creates there is no top-level duplicate field — both outcomes return the same body. To distinguish, compare created_at or pre-check with GET /v1/orders?external_reference=....

Creation fires order-created automations (plus order-paid when created as paid), writes the contact's activity timeline, and emits registered order.created (and order.paid) webhooks; a paid create records a completed payment for the full order total in the order's currency on the contact's payment history. Writes are attributed to source api.

Authorizations:
bearerAuth
Request Body schema: application/json
required
contact_id
string <uuid>

Existing contact (404 if not in this workspace). Wins over phone/email.

phone
string <= 32 characters

International format preferred.

email
string <email>
name
string <= 200 characters

Used only when a new contact is created.

Array of objects (Order item payload) <= 200 items
currency
string <= 8 characters

Uppercased. Omitted → workspace default currency.

discount_total
number [ 0 .. 9999999999 ]

Document-level discount.

shipping_total
number [ 0 .. 9999999999 ]
tax_total
number [ 0 .. 9999999999 ]
financial_status
string
Default: "pending"
Enum: "pending" "paid"

A paid create records the payment and fires order-paid automations. Never applied on an external_reference match.

placed_at
string <date-time>

ISO 8601; defaults to now.

coupon_codes
Array of strings <= 50 items
note
string <= 5000 characters
deal_id
string <uuid>

Must be a deal of the same contact — 404 ORDER_DEAL_NOT_FOUND when unknown to the workspace, 409 ORDER_DEAL_CONTACT_MISMATCH when it belongs to another contact.

external_reference
string <= 255 characters

Idempotency key, unique per workspace.

Responses

Request samples

Content type
application/json
{
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "phone": "string",
  • "email": "user@example.com",
  • "name": "string",
  • "items": [
    ],
  • "currency": "string",
  • "discount_total": 9999999999,
  • "shipping_total": 9999999999,
  • "tax_total": 9999999999,
  • "financial_status": "pending",
  • "placed_at": "2019-08-24T14:15:22Z",
  • "coupon_codes": [
    ],
  • "note": "string",
  • "deal_id": "2fa5b64f-5b72-4263-958e-07b76f41abe3",
  • "external_reference": "string"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "order_number": 0,
  • "number": "string",
  • "platform": "string",
  • "source": "string",
  • "store_connection_id": "b7a8f80c-9686-4d77-b9e3-5d9db722eef5",
  • "store_domain": "string",
  • "external_order_id": "string",
  • "external_reference": "string",
  • "deal_id": "2fa5b64f-5b72-4263-958e-07b76f41abe3",
  • "financial_status": "pending",
  • "fulfillment_status": "unfulfilled",
  • "currency": "string",
  • "total": 0,
  • "subtotal": 0,
  • "discount_total": 0,
  • "shipping_total": 0,
  • "tax_total": 0,
  • "refunded_total": 0,
  • "item_count": 0,
  • "first_item_name": "string",
  • "coupon_codes": [
    ],
  • "placed_at": "2019-08-24T14:15:22Z",
  • "paid_at": "2019-08-24T14:15:22Z",
  • "cancelled_at": "2019-08-24T14:15:22Z",
  • "refunded_at": "2019-08-24T14:15:22Z",
  • "external_updated_at": "2019-08-24T14:15:22Z",
  • "payment_reference": "string",
  • "payment_synced_at": "2019-08-24T14:15:22Z",
  • "note": "string",
  • "metadata": { },
  • "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "contact_name": "string",
  • "contact_phone": "string",
  • "contact_email": "string",
  • "items": [
    ],
  • "refunds": [
    ]
}

Get one order with items and refunds

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "id": "5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c",
  • "workspace_id": "0f1e2d3c-4b5a-4978-8796-a5b4c3d2e1f0",
  • "contact_id": "9e8d7c6b-5a49-4837-9605-f4e3d2c1b0a9",
  • "contact_name": "Dana Levi",
  • "contact_phone": "+972501234567",
  • "contact_email": "dana@example.com",
  • "order_number": 1042,
  • "number": null,
  • "platform": "api",
  • "source": "api",
  • "store_connection_id": null,
  • "store_domain": null,
  • "external_order_id": null,
  • "external_reference": "shop-88123",
  • "deal_id": null,
  • "financial_status": "pending",
  • "fulfillment_status": "unfulfilled",
  • "currency": "ILS",
  • "total": 360,
  • "subtotal": 340,
  • "discount_total": 0,
  • "shipping_total": 20,
  • "tax_total": 0,
  • "refunded_total": 0,
  • "item_count": 2,
  • "first_item_name": "Widget",
  • "coupon_codes": [ ],
  • "placed_at": "2026-07-14T09:58:00.000Z",
  • "paid_at": null,
  • "cancelled_at": null,
  • "refunded_at": null,
  • "external_updated_at": null,
  • "payment_reference": null,
  • "payment_synced_at": null,
  • "note": null,
  • "metadata": null,
  • "created_by": null,
  • "created_at": "2026-07-14T09:58:04.000Z",
  • "updated_at": "2026-07-14T09:58:04.000Z",
  • "items": [
    ],
  • "refunds": [ ]
}

Record a refund (idempotent via `external_refund_id`)

Appends to the order's append-only refund ledger, rolls financial_status to partially_refunded or refunded (a full-rollup refund → refunded; these two states are reachable only through refunds, never through a status endpoint), stamps refunded_at on the header, mirrors the refund into the recorded payment, and fires order-refunded automations + the order.refunded webhook.

Refunds require the order to have ever been paid (paid_at set — includes partially_paid); refunding a cancelled-but-paid order is allowed (cancellation doesn't touch the money axis). The amount must not exceed the remaining total (totalrefunded_total).

Idempotency: a repeat POST with the same external_refund_id applies nothing and returns duplicate: true with the current order state. Without external_refund_id, refunds are NOT idempotent — every POST appends a new refund; supply one whenever your system can retry.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Request Body schema: application/json
required
amount
required
number ( 0 .. 9999999999 ]

In the order's currency; must not exceed the remaining total (totalrefunded_total).

external_refund_id
string <= 255 characters

Idempotency key — a repeat POST with the same value on this order applies nothing and returns duplicate: true. Strongly recommended whenever your system can retry.

reason
string <= 1000 characters
refunded_at
string <date-time>

ISO 8601; defaults to now.

Responses

Request samples

Content type
application/json
{
  • "amount": 9999999999,
  • "external_refund_id": "string",
  • "reason": "string",
  • "refunded_at": "2019-08-24T14:15:22Z"
}

Response samples

Content type
application/json
{
  • "duplicate": false,
  • "order": {
    }
}

Mark an order paid

Moves financial_status to paid, records the payment — a completed one-time payment for the full order total in the order's currency on the contact's payment history — or links onto an existing payment via payment_reference, and fires order-paid automations + the order.paid webhook.

Transition rules: allowed from pending and partially_paid; an already-paid order is a no-op success (201, no side effects, no duplicate automations); from refunded/partially_refunded/voided → 409 ORDER_ILLEGAL_TRANSITION (refund states are set by recording refunds). paid_at is stamped on first entry into a paid state and is historical — it is kept through later refund states. A cancelled pending order can still be marked paid (the cancelled_at stamp does not affect the ladder).

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Request Body schema: application/json
payment_reference
string <= 255 characters

The external_reference of an existing payment row (e.g. one your system already recorded via POST /v1/payments) to link the order onto instead of recording a new payment. Link-only — the payment's amount is never rewritten. Validated before linking (see the 404/409 responses).

Responses

Request samples

Content type
application/json
{
  • "payment_reference": "string"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "order_number": 0,
  • "number": "string",
  • "platform": "string",
  • "source": "string",
  • "store_connection_id": "b7a8f80c-9686-4d77-b9e3-5d9db722eef5",
  • "store_domain": "string",
  • "external_order_id": "string",
  • "external_reference": "string",
  • "deal_id": "2fa5b64f-5b72-4263-958e-07b76f41abe3",
  • "financial_status": "pending",
  • "fulfillment_status": "unfulfilled",
  • "currency": "string",
  • "total": 0,
  • "subtotal": 0,
  • "discount_total": 0,
  • "shipping_total": 0,
  • "tax_total": 0,
  • "refunded_total": 0,
  • "item_count": 0,
  • "first_item_name": "string",
  • "coupon_codes": [
    ],
  • "placed_at": "2019-08-24T14:15:22Z",
  • "paid_at": "2019-08-24T14:15:22Z",
  • "cancelled_at": "2019-08-24T14:15:22Z",
  • "refunded_at": "2019-08-24T14:15:22Z",
  • "external_updated_at": "2019-08-24T14:15:22Z",
  • "payment_reference": "string",
  • "payment_synced_at": "2019-08-24T14:15:22Z",
  • "note": "string",
  • "metadata": { },
  • "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "contact_name": "string",
  • "contact_phone": "string",
  • "contact_email": "string",
  • "items": [
    ],
  • "refunds": [
    ]
}

Cancel an order

Stamps cancelled_at and fires order-cancelled automations + the order.cancelled webhook. No request body.

Cancellation is a stamp, not a status: financial_status keeps its last state — recorded revenue stands until a refund is recorded (cancel a paid order, then record refunds as money is returned). Idempotent: cancelling an already-cancelled order is a 201 no-op (no duplicate automations). A cancelled pending order can still be marked paid later — cancel is workflow state on the fulfillment side, not a financial transition.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "order_number": 0,
  • "number": "string",
  • "platform": "string",
  • "source": "string",
  • "store_connection_id": "b7a8f80c-9686-4d77-b9e3-5d9db722eef5",
  • "store_domain": "string",
  • "external_order_id": "string",
  • "external_reference": "string",
  • "deal_id": "2fa5b64f-5b72-4263-958e-07b76f41abe3",
  • "financial_status": "pending",
  • "fulfillment_status": "unfulfilled",
  • "currency": "string",
  • "total": 0,
  • "subtotal": 0,
  • "discount_total": 0,
  • "shipping_total": 0,
  • "tax_total": 0,
  • "refunded_total": 0,
  • "item_count": 0,
  • "first_item_name": "string",
  • "coupon_codes": [
    ],
  • "placed_at": "2019-08-24T14:15:22Z",
  • "paid_at": "2019-08-24T14:15:22Z",
  • "cancelled_at": "2019-08-24T14:15:22Z",
  • "refunded_at": "2019-08-24T14:15:22Z",
  • "external_updated_at": "2019-08-24T14:15:22Z",
  • "payment_reference": "string",
  • "payment_synced_at": "2019-08-24T14:15:22Z",
  • "note": "string",
  • "metadata": { },
  • "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "contact_name": "string",
  • "contact_phone": "string",
  • "contact_email": "string",
  • "items": [
    ],
  • "refunds": [
    ]
}

Emails

POST /v1/emails — idempotent raw transactional sends with opt-in tracking.

Send a transactional email (idempotent)

Sends a single transactional email through your workspace's verified sending domain. Raw-content endpoint: your HTML/text is delivered verbatim — no marketing footer, no unsubscribe link, and no tracking is injected unless you opt in. Requires at least one verified sending domain with a sender profile configured in the app.

Rate limit: 300 requests / minute per key (higher than the API default). Errors on this endpoint use the structured envelope {"error": {"code","message"}}.

Idempotency: the idempotency_key is claimed before any quota/deliverability gate, so two concurrent requests with the same key can't both send. Replaying a resolved key returns 200 with duplicate: true. Validation failures and most pre-send errors release the key. At-least-once corner case: in a rare crash-and-retry overlap right after a successful provider handoff, the message can be delivered twice while your retry receives 200 duplicate: true — design receivers so a duplicate email is acceptable.

Suppressed recipients are a 2xx, not an error — check status in the body; do not treat 2xx alone as "delivered to the provider". The suppression reason is deliberately coarse.

Tracking (opt-in): opens appends a 1×1 pixel to the HTML part; clicks wraps absolute http(s) links in the HTML part in a first-party redirect that forwards to your URL verbatim (mailto:, in-page anchors, relative URLs, and extremely long URLs are never wrapped). The plain-text part is never modified. If the deployment lacks a usable signing secret for tracking links, the message is sent untracked rather than failing. Open/click events arrive via the opt-in email.opened/email.clicked webhooks (first open / first click per send only).

Consent is your responsibility: this endpoint neither checks marketing consent nor creates oToK contact records for recipients. The suppression list is still enforced (fail-closed).

Authorizations:
bearerAuth
Request Body schema: application/json
required
to
required
string [ 1 .. 320 ] characters

Must be a syntactically valid email (else 422 invalid_recipient).

subject
required
string [ 1 .. 998 ] characters

No line breaks or control characters.

html
string

≤500 KB. Delivered verbatim. At least one of html/text is required; the missing part is derived from the other.

text
string

≤100 KB. At least one of html/text is required (text → HTML-escaped with line breaks preserved; html → a plain-text rendering).

idempotency_key
required
string [ 1 .. 255 ] characters

Unique per workspace — makes retries safe.

sender_profile_id
string <uuid>

Sender identity to use. Omitted → the workspace's default verified sender profile. An id from another workspace behaves like a nonexistent id (404).

reply_to
string <email>
object

Allowlist: only List-Unsubscribe and List-Unsubscribe-Post (case-insensitive). Values: strings, ≤1000 chars, no control characters. Any other header key → 400 validation_failed.

object

≤2048 bytes JSON-serialized. Stored with the send and echoed verbatim in webhook events (data.metadata).

object

Both default false. Preferences are not persisted across replays.

Responses

Request samples

Content type
application/json
{
  • "to": "string",
  • "subject": "string",
  • "html": "string",
  • "text": "string",
  • "idempotency_key": "string",
  • "sender_profile_id": "e604e9b6-6330-49cb-af8f-2abc7526b66b",
  • "reply_to": "user@example.com",
  • "headers": {
    },
  • "metadata": { },
  • "tracking": {
    }
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "status": "sent",
  • "duplicate": true,
  • "to": "string",
  • "idempotency_key": "string",
  • "provider_message_id": "string",
  • "reason": "string",
  • "created_at": "2019-08-24T14:15:22Z"
}

Suppressions

The workspace's email suppression list — a send-time overlay deliberately separate from consent. List, idempotent add, remove. Requires the email_marketing feature.

List suppressions

The workspace's email suppression rows, newest first. Workspace-scoped rows only — the HQ-managed global list is enforced at send time but is never returned here.

Authorizations:
bearerAuth
query Parameters
email
string

Exact-match filter (case-insensitive).

limit
integer [ 0 .. 500 ]
Default: 50

Hard cap 500. Non-integer or negative → 400.

offset
integer >= 0
Default: 0

Non-integer or negative → 400.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "total": 0,
  • "limit": 0,
  • "offset": 0
}

Suppress an email address (idempotent)

Blocks every future send to this address, independent of consent — adding a suppression does NOT change the contact's consent state; the two compose at send time. Re-adding an already-suppressed address returns the existing row with duplicate: true (201 for both outcomes).

Authorizations:
bearerAuth
Request Body schema: application/json
required
email
required
string <email> <= 255 characters
reason
string
Enum: "unsubscribe" "bounce" "complaint" "manual"

Why the address is suppressed. Defaults to manual.

note
string <= 500 characters

Free-form note stored with the row.

Responses

Request samples

Content type
application/json
{
  • "email": "user@example.com",
  • "reason": "unsubscribe",
  • "note": "string"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "email": "string",
  • "reason": "unsubscribe",
  • "source": "string",
  • "note": "string",
  • "created_at": "2019-08-24T14:15:22Z",
  • "duplicate": true
}

Remove a suppression

Lifts this workspace's suppression only — it does NOT resubscribe anyone (consent state is unchanged), and HQ-managed global rows cannot be removed through the API.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "statusCode": 0,
  • "message": "string",
  • "error": "string",
  • "error_code": "string"
}

Audiences

Read-only discovery of the workspace's saved audiences — the reusable targeting selectors campaigns and email campaigns accept as audience_id. Rows never include the stored definition; audiences are managed in-app and are not writable through the API.

List audiences

Read-only discovery of the workspace's saved audiences — the reusable targeting selectors POST /v1/campaigns and POST /v1/email-campaigns accept as audience_id. Newest first. Rows carry the summary columns plus the advisory last_count/last_counted_at size cache — never the stored definition (see the schema). Audiences are managed in-app and are not writable through the public API. An empty kind value (?kind=) is treated as absent; an unknown value returns 400.

Authorizations:
bearerAuth
query Parameters
kind
string
Enum: "dynamic" "static"

Exact kind filter. Unknown values return 400 ("Invalid kind: must be one of dynamic, static"); empty is treated as absent.

limit
integer [ 1 .. 100 ]
Default: 25

Default 25, cap 100. Absent or empty values default; a malformed value returns 400 "Invalid limit: must be a non-negative integer".

offset
integer >= 0
Default: 0

Default 0, min 0. Absent or empty values default; a malformed value returns 400 "Invalid offset: must be a non-negative integer".

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "total": 0,
  • "limit": 0,
  • "offset": 0
}

Sender profiles

Read-only discovery of the workspace's email from-identities — the selectors email campaigns accept as sender_profile_id, each with its sending domain's verification status. Managed in-app; requires the email_marketing feature.

List email sender profiles

Read-only discovery of the workspace's email from-identities — the selectors POST /v1/email-campaigns accepts as sender_profile_id. Newest first. Each row carries the from identity (from_name, the composed from_email, reply_to), the delivery provider, the default flag, and the sending-domain linkage with its verification status — prefer profiles with verified: true when creating campaigns (see the schema). DKIM/DNS material is never returned. Profiles are managed in-app and are not writable through the public API.

Authorizations:
bearerAuth
query Parameters
limit
integer [ 1 .. 100 ]
Default: 25

Default 25, cap 100. Absent or empty values default; a malformed value returns 400 "Invalid limit: must be a non-negative integer".

offset
integer >= 0
Default: 0

Default 0, min 0. Absent or empty values default; a malformed value returns 400 "Invalid offset: must be a non-negative integer".

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "total": 0,
  • "limit": 0,
  • "offset": 0
}

Email campaigns

Broadcast email campaigns — author (markdown / blocks / design_json), estimate, launch, schedule. Idempotent create via external_reference. Requires the email_marketing feature.

List email campaigns

Newest first. List rows omit the content columns (design_json, compiled_html, compiled_styles, plain_text) and the in-app-only A/B fields — fetch a single campaign for the content. Delivery counters are included on list rows. An empty status value (?status=) is treated as absent; an unknown value returns 400.

Authorizations:
bearerAuth
query Parameters
status
string
Enum: "draft" "scheduled" "sending" "paused" "sent" "failed" "cancelled"

Exact status filter. Unknown values return 400 ("Invalid status: must be one of draft, scheduled, sending, paused, sent, failed, cancelled"); empty is treated as absent.

limit
integer [ 1 .. 100 ]
Default: 25

Default 25, cap 100. Absent or empty values default; a malformed value returns 400 "Invalid limit: must be a non-negative integer".

offset
integer >= 0
Default: 0

Default 0, min 0. Absent or empty values default; a malformed value returns 400 "Invalid offset: must be a non-negative integer".

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "total": 0,
  • "limit": 0,
  • "offset": 0
}

Create a draft campaign (idempotent upsert via `external_reference`)

Creates a draft broadcast email campaign. content (see the Content input schema) maps to the campaign's email body and compiles immediately through the same pipeline the in-app launch gate runs — the response's compile: {ok, errors, warnings} envelope reports the result up front, not at send time. The campaign does not send until you call POST /v1/email-campaigns/{id}/send or …/schedule.

Targeting is optional and validated at write time: audience_id must belong to your workspace, audience_filters is structurally validated and test-compiled (failures return 400 with an errors array). At send time the audience can only narrow the built-in email send-eligibility baseline (opted in, not suppressed) — never widen it. With no targeting at all, the campaign goes to every eligible contact — check GET /v1/email-campaigns/{id}/estimate before sending.

Idempotency: when external_reference matches an existing campaign in the workspace, the response carries duplicate: true and:

  • while the campaign is still draft/scheduled, the call updates its fields exactly as a PATCH would — never status or scheduled_at;
  • once the launch has claimed it (sending/sent/failed/…), the campaign is returned verbatim — nothing is mutated, nothing is recompiled, and the response carries no compile envelope.

The response is 201 in both cases. One reference maps to one campaign per workspace. A/B testing is deliberately not exposed on the public API — create A/B campaigns in-app.

Authorizations:
bearerAuth
Request Body schema: application/json
required
name
required
string [ 1 .. 200 ] characters
subject
required
string [ 1 .. 400 ] characters

May embed [[…]] variable tokens.

preheader
string <= 400 characters

The inbox preview line rendered after the subject.

sender_profile_id
required
string <uuid>

A sender profile in this workspace (unknown → 400 sender_profile_not_found). Send-readiness — verified domain + legal footer fields — is asserted at launch, not here.

external_reference
string <= 255 characters

Idempotency key: a repeat POST with the same value updates the matched campaign (while draft/scheduled) instead of creating a duplicate, and the response carries duplicate: true.

required
object (ContentInput)

The shared authoring contract for email-campaign bodies and newsletter issues: an optional direction plus exactly one of markdown, blocks, or design_json — zero or two-plus sources return 400 invalid_content. Whichever source you send is converted to the same internal email document the in-app editor produces and compiled immediately; the write response's compile envelope reports errors and warnings up front, not at send time.

Total content size is capped at 512,000 characters (measured on the source and again on the built document) — an oversized body returns 400 invalid_content.

Variable tokens work in all three sources: any [[…]] token in text becomes a personalization pill resolved per recipient at send time. The token grammar is [[path]], [[path : fallback]], or [[path | modifier(arg) : fallback]]path is a contact field (first_name, email, custom field keys, or the explicit contact. prefix), the optional fallback renders when the value is empty, and optional modifiers format the value (e.g. upper, date). Example: Hi [[first_name : there]]!. Tokens also work inside the campaign subject and preheader.

Snippets (reusable content blocks from the workspace library, managed in Settings → Snippets) can be spliced in by UUID or by case-insensitive exact name — via the ::snippet[…] markdown directive or a snippet block. A reference that matches no workspace snippet returns 400 unknown_snippet with the available names listed in the message. The stored document keeps the reference, but the two surfaces resolve it at different moments: email campaigns re-expand snippet references at launch, so a snippet edited after the campaign was written still reaches the send; newsletter issues bake snippet content into the compiled document at save — re-save the issue (PATCH, or an external_reference create replay) to pick up later snippet edits.

audience_id
string <uuid>

A saved audience; wins over audience_filters. Must belong to your workspace (404 otherwise).

audience_filters
object

An ad-hoc $where condition tree; validated structurally and test-compiled at write time (failures return 400 with an errors array). Ignored when audience_id is set.

contact_group_ids
Array of strings <uuid> [ items <uuid > ]

Additional contact-group targeting (OR semantics), narrowing the audience.

topic_key
string <= 200 characters

Preference-center topic key — contacts who opted out of it are excluded.

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "subject": "Big news, [[first_name : there]]!",
  • "preheader": "string",
  • "sender_profile_id": "e604e9b6-6330-49cb-af8f-2abc7526b66b",
  • "external_reference": "string",
  • "content": {
    },
  • "audience_id": "875075bb-caff-4035-8f5f-9fb1430dc417",
  • "audience_filters": { },
  • "contact_group_ids": [
    ],
  • "topic_key": "string"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "string",
  • "status": "draft",
  • "sender_profile_id": "e604e9b6-6330-49cb-af8f-2abc7526b66b",
  • "subject": "string",
  • "preheader": "string",
  • "direction": "rtl",
  • "topic_key": "string",
  • "contact_group_ids": [
    ],
  • "audience_id": "875075bb-caff-4035-8f5f-9fb1430dc417",
  • "audience_filters": { },
  • "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
  • "design_json": { },
  • "compiled_html": "string",
  • "compiled_styles": "string",
  • "plain_text": "string",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "timezone": "string",
  • "started_at": "2019-08-24T14:15:22Z",
  • "completed_at": "2019-08-24T14:15:22Z",
  • "total_recipients": 0,
  • "sent_count": 0,
  • "delivered_count": 0,
  • "open_count": 0,
  • "click_count": 0,
  • "bounce_count": 0,
  • "complaint_count": 0,
  • "unsubscribe_count": 0,
  • "failed_count": 0,
  • "skipped_count": 0,
  • "pending_retry_count": 0,
  • "external_reference": "string",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "duplicate": true,
  • "compile": {
    }
}

Get one campaign

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "string",
  • "status": "draft",
  • "sender_profile_id": "e604e9b6-6330-49cb-af8f-2abc7526b66b",
  • "subject": "string",
  • "preheader": "string",
  • "direction": "rtl",
  • "topic_key": "string",
  • "contact_group_ids": [
    ],
  • "audience_id": "875075bb-caff-4035-8f5f-9fb1430dc417",
  • "audience_filters": { },
  • "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
  • "design_json": { },
  • "compiled_html": "string",
  • "compiled_styles": "string",
  • "plain_text": "string",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "timezone": "string",
  • "started_at": "2019-08-24T14:15:22Z",
  • "completed_at": "2019-08-24T14:15:22Z",
  • "total_recipients": 0,
  • "sent_count": 0,
  • "delivered_count": 0,
  • "open_count": 0,
  • "click_count": 0,
  • "bounce_count": 0,
  • "complaint_count": 0,
  • "unsubscribe_count": 0,
  • "failed_count": 0,
  • "skipped_count": 0,
  • "pending_retry_count": 0,
  • "external_reference": "string",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Update a draft/scheduled campaign

Same field set as create minus external_reference; only present fields are touched, and the nullable fields clear on an explicit null. A content change recompiles (the response carries a fresh compile envelope) and detaches any in-app template the campaign referenced — the patched content is what sends. Campaigns the launch already claimed (sending/sent/failed/…) answer 409 campaign_not_editable; a scheduled campaign stays scheduled (PATCH never touches status or scheduled_at — use …/schedule to move the launch time).

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Request Body schema: application/json
required
name
string [ 1 .. 200 ] characters
subject
string or null [ 1 .. 400 ] characters

null clears it; an empty string is rejected.

preheader
string or null <= 400 characters
sender_profile_id
string <uuid>
object (ContentInput)

The shared authoring contract for email-campaign bodies and newsletter issues: an optional direction plus exactly one of markdown, blocks, or design_json — zero or two-plus sources return 400 invalid_content. Whichever source you send is converted to the same internal email document the in-app editor produces and compiled immediately; the write response's compile envelope reports errors and warnings up front, not at send time.

Total content size is capped at 512,000 characters (measured on the source and again on the built document) — an oversized body returns 400 invalid_content.

Variable tokens work in all three sources: any [[…]] token in text becomes a personalization pill resolved per recipient at send time. The token grammar is [[path]], [[path : fallback]], or [[path | modifier(arg) : fallback]]path is a contact field (first_name, email, custom field keys, or the explicit contact. prefix), the optional fallback renders when the value is empty, and optional modifiers format the value (e.g. upper, date). Example: Hi [[first_name : there]]!. Tokens also work inside the campaign subject and preheader.

Snippets (reusable content blocks from the workspace library, managed in Settings → Snippets) can be spliced in by UUID or by case-insensitive exact name — via the ::snippet[…] markdown directive or a snippet block. A reference that matches no workspace snippet returns 400 unknown_snippet with the available names listed in the message. The stored document keeps the reference, but the two surfaces resolve it at different moments: email campaigns re-expand snippet references at launch, so a snippet edited after the campaign was written still reaches the send; newsletter issues bake snippet content into the compiled document at save — re-save the issue (PATCH, or an external_reference create replay) to pick up later snippet edits.

audience_id
string or null <uuid>
audience_filters
object or null
contact_group_ids
Array of strings or null <uuid> [ items <uuid > ]
topic_key
string or null <= 200 characters

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "subject": "string",
  • "preheader": "string",
  • "sender_profile_id": "e604e9b6-6330-49cb-af8f-2abc7526b66b",
  • "content": {
    },
  • "audience_id": "875075bb-caff-4035-8f5f-9fb1430dc417",
  • "audience_filters": { },
  • "contact_group_ids": [
    ],
  • "topic_key": "string"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "string",
  • "status": "draft",
  • "sender_profile_id": "e604e9b6-6330-49cb-af8f-2abc7526b66b",
  • "subject": "string",
  • "preheader": "string",
  • "direction": "rtl",
  • "topic_key": "string",
  • "contact_group_ids": [
    ],
  • "audience_id": "875075bb-caff-4035-8f5f-9fb1430dc417",
  • "audience_filters": { },
  • "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
  • "design_json": { },
  • "compiled_html": "string",
  • "compiled_styles": "string",
  • "plain_text": "string",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "timezone": "string",
  • "started_at": "2019-08-24T14:15:22Z",
  • "completed_at": "2019-08-24T14:15:22Z",
  • "total_recipients": 0,
  • "sent_count": 0,
  • "delivered_count": 0,
  • "open_count": 0,
  • "click_count": 0,
  • "bounce_count": 0,
  • "complaint_count": 0,
  • "unsubscribe_count": 0,
  • "failed_count": 0,
  • "skipped_count": 0,
  • "pending_retry_count": 0,
  • "external_reference": "string",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "duplicate": true,
  • "compile": {
    }
}

Estimate the campaign's audience size

Runs the campaign's stored targeting through the same resolver pipeline the send path uses — the email consent + suppression baseline, then the saved audience or ad-hoc filters, contact-group narrowing, and topic_key opt-outs — so the estimate always matches send-time resolution (minus recipients already claimed mid-flight). Update targeting first (PATCH), then re-estimate.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "estimated_recipients": 1284
}

Launch the campaign now

Draft/scheduled campaigns only. No request body. Marks the campaign scheduled for now and drives the launch synchronously through the exact in-app launch gates — sender readiness (verified domain + legal footer fields), inline content compile, and the content lint. On success the response is the campaign with its post-launch status (typically sending; poll GET /v1/email-campaigns/{id} for counter progress). A gate failure answers 422 with the gate's message and the campaign's final status — the gate marks the campaign failed as a side effect, so error.campaign_status tells you where it landed.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "string",
  • "status": "draft",
  • "sender_profile_id": "e604e9b6-6330-49cb-af8f-2abc7526b66b",
  • "subject": "string",
  • "preheader": "string",
  • "direction": "rtl",
  • "topic_key": "string",
  • "contact_group_ids": [
    ],
  • "audience_id": "875075bb-caff-4035-8f5f-9fb1430dc417",
  • "audience_filters": { },
  • "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
  • "design_json": { },
  • "compiled_html": "string",
  • "compiled_styles": "string",
  • "plain_text": "string",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "timezone": "string",
  • "started_at": "2019-08-24T14:15:22Z",
  • "completed_at": "2019-08-24T14:15:22Z",
  • "total_recipients": 0,
  • "sent_count": 0,
  • "delivered_count": 0,
  • "open_count": 0,
  • "click_count": 0,
  • "bounce_count": 0,
  • "complaint_count": 0,
  • "unsubscribe_count": 0,
  • "failed_count": 0,
  • "skipped_count": 0,
  • "pending_retry_count": 0,
  • "external_reference": "string",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Schedule (or reschedule) a future launch

Draft/scheduled campaigns only. scheduled_at is an ISO 8601 UTC instant in the future; the every-minute sweep launches the campaign when due (running the same launch gates as …/send). Calling it again while still scheduled moves the launch time.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Request Body schema: application/json
required
scheduled_at
required
string <date-time>

ISO 8601 UTC instant; must be in the future (otherwise 400 invalid_scheduled_at). A non-ISO-8601 value fails request validation with the standard body.

Responses

Request samples

Content type
application/json
{
  • "scheduled_at": "2026-08-01T09:00:00Z"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "string",
  • "status": "draft",
  • "sender_profile_id": "e604e9b6-6330-49cb-af8f-2abc7526b66b",
  • "subject": "string",
  • "preheader": "string",
  • "direction": "rtl",
  • "topic_key": "string",
  • "contact_group_ids": [
    ],
  • "audience_id": "875075bb-caff-4035-8f5f-9fb1430dc417",
  • "audience_filters": { },
  • "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
  • "design_json": { },
  • "compiled_html": "string",
  • "compiled_styles": "string",
  • "plain_text": "string",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "timezone": "string",
  • "started_at": "2019-08-24T14:15:22Z",
  • "completed_at": "2019-08-24T14:15:22Z",
  • "total_recipients": 0,
  • "sent_count": 0,
  • "delivered_count": 0,
  • "open_count": 0,
  • "click_count": 0,
  • "bounce_count": 0,
  • "complaint_count": 0,
  • "unsubscribe_count": 0,
  • "failed_count": 0,
  • "skipped_count": 0,
  • "pending_retry_count": 0,
  • "external_reference": "string",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Cancel a scheduled launch (back to draft)

No request body. Conditional on status scheduled — when the minute sweep has already claimed the campaign for sending, the call answers 409 already_sending rather than silently no-opping, so you know the sends are going out.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "string",
  • "status": "draft",
  • "sender_profile_id": "e604e9b6-6330-49cb-af8f-2abc7526b66b",
  • "subject": "string",
  • "preheader": "string",
  • "direction": "rtl",
  • "topic_key": "string",
  • "contact_group_ids": [
    ],
  • "audience_id": "875075bb-caff-4035-8f5f-9fb1430dc417",
  • "audience_filters": { },
  • "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
  • "design_json": { },
  • "compiled_html": "string",
  • "compiled_styles": "string",
  • "plain_text": "string",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "timezone": "string",
  • "started_at": "2019-08-24T14:15:22Z",
  • "completed_at": "2019-08-24T14:15:22Z",
  • "total_recipients": 0,
  • "sent_count": 0,
  • "delivered_count": 0,
  • "open_count": 0,
  • "click_count": 0,
  • "bounce_count": 0,
  • "complaint_count": 0,
  • "unsubscribe_count": 0,
  • "failed_count": 0,
  • "skipped_count": 0,
  • "pending_retry_count": 0,
  • "external_reference": "string",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Newsletters

Smart newsletters and their sequenced issues — create, author, publish, schedule. Idempotent issue create via external_reference; issue numbers are assigned at publish. Requires the newsletters feature.

List newsletters

Newest first. Each row carries its computed active_subscriber_count. List rows return the documented summary fields only — fetch a single newsletter for the full stored configuration.

Authorizations:
bearerAuth
query Parameters
limit
integer [ 1 .. 100 ]
Default: 25

Default 25, cap 100. Absent or empty values default; a malformed value returns 400 "Invalid limit: must be a non-negative integer".

offset
integer >= 0
Default: 0

Default 0, min 0. Absent or empty values default; a malformed value returns 400 "Invalid offset: must be a non-negative integer".

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "total": 0,
  • "limit": 0,
  • "offset": 0
}

Create a newsletter

A name alone suffices — cadence, enrollment policy, and archive settings take their in-app defaults (adjust them in the app). Omit sender_profile_id to fall back to the workspace default profile at send time. Newsletter names are unique per workspace (case-insensitive) — a duplicate answers 409 duplicate_name. Subject to the plan's newsletter cap (403 PLAN_LIMIT_EXCEEDED when reached).

Authorizations:
bearerAuth
Request Body schema: application/json
required
name
required
string [ 1 .. 120 ] characters

Unique per workspace, case-insensitive (409 duplicate_name).

description
string <= 2000 characters
sender_profile_id
string <uuid>

Omit to fall back to the workspace default profile at send time. Unknown → 400 sender_profile_not_found.

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "description": "string",
  • "sender_profile_id": "e604e9b6-6330-49cb-af8f-2abc7526b66b"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "string",
  • "description": "string",
  • "status": "active",
  • "sender_profile_id": "e604e9b6-6330-49cb-af8f-2abc7526b66b",
  • "active_subscriber_count": 0,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Get one newsletter

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "string",
  • "description": "string",
  • "status": "active",
  • "sender_profile_id": "e604e9b6-6330-49cb-af8f-2abc7526b66b",
  • "active_subscriber_count": 0,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

List a newsletter's issues

Newest first. List rows omit the content columns (design_json, compiled_html, compiled_styles, plain_text) — fetch a single issue for those. An empty status value (?status=) is treated as absent; an unknown value returns 400 ("Invalid status: must be draft, scheduled or published").

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

query Parameters
status
string
Enum: "draft" "scheduled" "published"

Exact status filter. Unknown values return 400; empty is treated as absent.

limit
integer [ 1 .. 100 ]
Default: 25

Default 25, cap 100. Absent or empty values default; a malformed value returns 400 "Invalid limit: must be a non-negative integer".

offset
integer >= 0
Default: 0

Default 0, min 0. Absent or empty values default; a malformed value returns 400 "Invalid offset: must be a non-negative integer".

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "total": 0,
  • "limit": 0,
  • "offset": 0
}

Create a draft issue (idempotent upsert via `external_reference`)

Creates a draft issue. All fields are optional — an issue can start as an empty placeholder — but publishing/scheduling later requires a subject and compiled content. content (see the Content input schema) maps to the issue's email body and compiles immediately; the response's compile: {ok, errors, warnings} envelope reports the result.

Idempotency: when external_reference matches an existing issue in the workspace, the call updates that issue's content/fields instead of creating a new one and answers duplicate: true — a replay never touches status, scheduled_at, or issue_number, so re-running an export pipeline can refresh a draft's content without re-publishing anything. One reference maps to one issue per workspace; a reference that belongs to an issue of a different newsletter answers 409 external_reference_in_use. The response is 201 in both cases.

Issue numbers are assigned at publish (issue_number stays null on drafts): issue #N always means the Nth published issue.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Request Body schema: application/json
required
subject
string <= 400 characters

May embed [[…]] variable tokens.

preheader
string <= 400 characters
include_in_archive
boolean
Default: true
external_reference
string <= 255 characters

Idempotency key: a repeat POST with the same value updates the matched issue's content/fields instead of creating a duplicate (duplicate: true in the response) — never its status, scheduled_at, or issue_number.

object (ContentInput)

The shared authoring contract for email-campaign bodies and newsletter issues: an optional direction plus exactly one of markdown, blocks, or design_json — zero or two-plus sources return 400 invalid_content. Whichever source you send is converted to the same internal email document the in-app editor produces and compiled immediately; the write response's compile envelope reports errors and warnings up front, not at send time.

Total content size is capped at 512,000 characters (measured on the source and again on the built document) — an oversized body returns 400 invalid_content.

Variable tokens work in all three sources: any [[…]] token in text becomes a personalization pill resolved per recipient at send time. The token grammar is [[path]], [[path : fallback]], or [[path | modifier(arg) : fallback]]path is a contact field (first_name, email, custom field keys, or the explicit contact. prefix), the optional fallback renders when the value is empty, and optional modifiers format the value (e.g. upper, date). Example: Hi [[first_name : there]]!. Tokens also work inside the campaign subject and preheader.

Snippets (reusable content blocks from the workspace library, managed in Settings → Snippets) can be spliced in by UUID or by case-insensitive exact name — via the ::snippet[…] markdown directive or a snippet block. A reference that matches no workspace snippet returns 400 unknown_snippet with the available names listed in the message. The stored document keeps the reference, but the two surfaces resolve it at different moments: email campaigns re-expand snippet references at launch, so a snippet edited after the campaign was written still reaches the send; newsletter issues bake snippet content into the compiled document at save — re-save the issue (PATCH, or an external_reference create replay) to pick up later snippet edits.

Responses

Request samples

Content type
application/json
{
  • "subject": "string",
  • "preheader": "string",
  • "include_in_archive": true,
  • "external_reference": "string",
  • "content": {
    }
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "newsletter_id": "854c5e2d-fe5e-4354-976d-dc3015528911",
  • "issue_number": 0,
  • "subject": "string",
  • "preheader": "string",
  • "status": "draft",
  • "design_json": { },
  • "compiled_html": "string",
  • "compiled_styles": "string",
  • "plain_text": "string",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "published_at": "2019-08-24T14:15:22Z",
  • "include_in_archive": true,
  • "external_reference": "string",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "duplicate": true,
  • "compile": {
    }
}

Get one issue

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "newsletter_id": "854c5e2d-fe5e-4354-976d-dc3015528911",
  • "issue_number": 0,
  • "subject": "string",
  • "preheader": "string",
  • "status": "draft",
  • "design_json": { },
  • "compiled_html": "string",
  • "compiled_styles": "string",
  • "plain_text": "string",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "published_at": "2019-08-24T14:15:22Z",
  • "include_in_archive": true,
  • "external_reference": "string",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Update an issue

Only present fields are touched; subject/preheader clear on an explicit null. A content change recompiles (fresh compile envelope). Published issues stay editable — a typo fix recompiles for future catch-up deliveries; already-delivered copies are naturally immutable. Content cannot be cleared through the API (content: null fails validation) — replace it with new content, or manage that in-app.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Request Body schema: application/json
required
subject
string or null <= 400 characters
preheader
string or null <= 400 characters
include_in_archive
boolean
object (ContentInput)

The shared authoring contract for email-campaign bodies and newsletter issues: an optional direction plus exactly one of markdown, blocks, or design_json — zero or two-plus sources return 400 invalid_content. Whichever source you send is converted to the same internal email document the in-app editor produces and compiled immediately; the write response's compile envelope reports errors and warnings up front, not at send time.

Total content size is capped at 512,000 characters (measured on the source and again on the built document) — an oversized body returns 400 invalid_content.

Variable tokens work in all three sources: any [[…]] token in text becomes a personalization pill resolved per recipient at send time. The token grammar is [[path]], [[path : fallback]], or [[path | modifier(arg) : fallback]]path is a contact field (first_name, email, custom field keys, or the explicit contact. prefix), the optional fallback renders when the value is empty, and optional modifiers format the value (e.g. upper, date). Example: Hi [[first_name : there]]!. Tokens also work inside the campaign subject and preheader.

Snippets (reusable content blocks from the workspace library, managed in Settings → Snippets) can be spliced in by UUID or by case-insensitive exact name — via the ::snippet[…] markdown directive or a snippet block. A reference that matches no workspace snippet returns 400 unknown_snippet with the available names listed in the message. The stored document keeps the reference, but the two surfaces resolve it at different moments: email campaigns re-expand snippet references at launch, so a snippet edited after the campaign was written still reaches the send; newsletter issues bake snippet content into the compiled document at save — re-save the issue (PATCH, or an external_reference create replay) to pick up later snippet edits.

Responses

Request samples

Content type
application/json
{
  • "subject": "string",
  • "preheader": "string",
  • "include_in_archive": true,
  • "content": {
    }
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "newsletter_id": "854c5e2d-fe5e-4354-976d-dc3015528911",
  • "issue_number": 0,
  • "subject": "string",
  • "preheader": "string",
  • "status": "draft",
  • "design_json": { },
  • "compiled_html": "string",
  • "compiled_styles": "string",
  • "plain_text": "string",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "published_at": "2019-08-24T14:15:22Z",
  • "include_in_archive": true,
  • "external_reference": "string",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "duplicate": true,
  • "compile": {
    }
}

Delete a draft/scheduled issue

Published issues can never be deleted — deleting one would free its issue number and corrupt subscriber catch-up cursors. Hide one from the public archive with include_in_archive: false instead.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "success": true
}

Publish an issue now

No request body. Assigns the next issue_number (per-newsletter, monotonic) and wakes caught-up subscribers — delivery behaves exactly like an in-app publish: caught-up subscribers receive the issue on the next delivery sweep, while new or behind subscribers reach it through their catch-up drip. Requires a subject and compiled content (409 issue_missing_content otherwise). Idempotent — an already-published issue is returned as-is with a 200.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "newsletter_id": "854c5e2d-fe5e-4354-976d-dc3015528911",
  • "issue_number": 0,
  • "subject": "string",
  • "preheader": "string",
  • "status": "draft",
  • "design_json": { },
  • "compiled_html": "string",
  • "compiled_styles": "string",
  • "plain_text": "string",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "published_at": "2019-08-24T14:15:22Z",
  • "include_in_archive": true,
  • "external_reference": "string",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Schedule (or reschedule) a future publish

scheduled_at is an ISO 8601 UTC instant in the future; the every-minute sweep publishes the issue when due (same effects as …/publish). Requires a subject and compiled content up front, so the sweep can't hit a content check failure later. Calling it again while still scheduled moves the publish time.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Request Body schema: application/json
required
scheduled_at
required
string <date-time>

ISO 8601 UTC instant; must be in the future (otherwise 400 invalid_scheduled_at). A non-ISO-8601 value fails request validation with the standard body.

Responses

Request samples

Content type
application/json
{
  • "scheduled_at": "2026-08-01T09:00:00Z"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "newsletter_id": "854c5e2d-fe5e-4354-976d-dc3015528911",
  • "issue_number": 0,
  • "subject": "string",
  • "preheader": "string",
  • "status": "draft",
  • "design_json": { },
  • "compiled_html": "string",
  • "compiled_styles": "string",
  • "plain_text": "string",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "published_at": "2019-08-24T14:15:22Z",
  • "include_in_archive": true,
  • "external_reference": "string",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Cancel a scheduled publish (back to draft)

No request body. Only currently scheduled issues can be unscheduled.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "newsletter_id": "854c5e2d-fe5e-4354-976d-dc3015528911",
  • "issue_number": 0,
  • "subject": "string",
  • "preheader": "string",
  • "status": "draft",
  • "design_json": { },
  • "compiled_html": "string",
  • "compiled_styles": "string",
  • "plain_text": "string",
  • "scheduled_at": "2019-08-24T14:15:22Z",
  • "published_at": "2019-08-24T14:15:22Z",
  • "include_in_archive": true,
  • "external_reference": "string",
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Webhook endpoints

Register receivers for outbound events — email delivery/engagement events on API-originated sends, plus the opt-in order.*, payment_request.*, contact.*, message.received, deal.*, booking.*, event.attendance.changed, and form.submitted families.

email.delivered — the provider confirmed delivery of an API send Webhook

Default-subscription delivery event.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
string (Email event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked"

email.delivered/email.bounced/email.complained are the default subscription; email.opened/email.clicked are opt-in. email.failed is deprecated — still accepted at registration (echoed in events) but never delivered; nothing produces this event, and a failing POST /v1/emails send fails synchronously on the request itself.

Value: "email.delivered"
created_at
required
string <date-time>

When the event occurred.

required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "email.delivered",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

email.bounced — the send bounced Webhook

Default-subscription delivery event. data.bounce_type is included when known: hard, soft, or block.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
string (Email event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked"

email.delivered/email.bounced/email.complained are the default subscription; email.opened/email.clicked are opt-in. email.failed is deprecated — still accepted at registration (echoed in events) but never delivered; nothing produces this event, and a failing POST /v1/emails send fails synchronously on the request itself.

Value: "email.bounced"
created_at
required
string <date-time>

When the event occurred.

required
object

Responses

Request samples

Content type
application/json
{
  • "id": "b2c3d4e5-f607-1829-3a4b-5c6d7e8f9012",
  • "type": "email.bounced",
  • "created_at": "2026-07-14T10:05:32.000Z",
  • "data": {
    }
}

email.complained — the recipient marked the message as spam (feedback loop) Webhook

Default-subscription delivery event.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
string (Email event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked"

email.delivered/email.bounced/email.complained are the default subscription; email.opened/email.clicked are opt-in. email.failed is deprecated — still accepted at registration (echoed in events) but never delivered; nothing produces this event, and a failing POST /v1/emails send fails synchronously on the request itself.

Value: "email.complained"
created_at
required
string <date-time>

When the event occurred.

required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "email.complained",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

email.failed — deprecated (never delivered) Deprecated Webhook

Deprecated — never delivered; nothing produces this event. It is no longer part of the default subscription set. Listing it explicitly at registration still succeeds (no error, no warning; it is echoed in events) so existing integrations keep working, but no delivery will ever arrive. A failing POST /v1/emails send fails synchronously on the request itself (e.g. 502 provider_error, 409 send_in_progress) — handle send failures from the send response.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
string (Email event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked"

email.delivered/email.bounced/email.complained are the default subscription; email.opened/email.clicked are opt-in. email.failed is deprecated — still accepted at registration (echoed in events) but never delivered; nothing produces this event, and a failing POST /v1/emails send fails synchronously on the request itself.

Value: "email.failed"
created_at
required
string <date-time>

When the event occurred.

required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "email.failed",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

email.opened — first open only (opt-in) Webhook

Opt-in engagement event: at most one per send, for sends with tracking.opens. data.machine_open is always present — true means the open was attributed to an automated mail scanner / prefetcher (e.g. Apple Mail privacy) rather than a human. Machine opens are forwarded flagged, never dropped.

A click does not emit an implied email.opened event — treat a send as opened when you've received either email.opened or email.clicked.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
string (Email event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked"

email.delivered/email.bounced/email.complained are the default subscription; email.opened/email.clicked are opt-in. email.failed is deprecated — still accepted at registration (echoed in events) but never delivered; nothing produces this event, and a failing POST /v1/emails send fails synchronously on the request itself.

Value: "email.opened"
created_at
required
string <date-time>

When the event occurred.

required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "email.opened",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

email.clicked — first click only (opt-in) Webhook

Opt-in engagement event: at most one per send, for sends with tracking.clicks. data.url is always present: the original destination URL (may be an empty string in rare cases). Clicks carry no machine flag.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
string (Email event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked"

email.delivered/email.bounced/email.complained are the default subscription; email.opened/email.clicked are opt-in. email.failed is deprecated — still accepted at registration (echoed in events) but never delivered; nothing produces this event, and a failing POST /v1/emails send fails synchronously on the request itself.

Value: "email.clicked"
created_at
required
string <date-time>

When the event occurred.

required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "email.clicked",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

order.created — an order was created Webhook

Opt-in order lifecycle event — delivered only to endpoints that list it explicitly in events. Fires for every order write source (API, in-app, automations), never for historical import ingestion. A paid create also emits order.paid.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
string (Order event type)
Enum: "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled"

The five order lifecycle events. All are opt-in: delivered only to endpoints that list them explicitly in events — an endpoint registered without an events list receives none of them. They fire for every order write source (API, in-app, automations), never for historical import ingestion. Payload shape: OrderEventBase (plus the refund block on OrderRefundedEvent).

Value: "order.created"
created_at
required
string <date-time>

When the event occurred.

required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "order.created",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

order.paid — the order entered a paid state Webhook

Opt-in order lifecycle event — delivered only to endpoints that list it explicitly in events. Fires when the order enters a paid state: a create with financial_status: "paid", or POST /v1/orders/{id}/mark-paid (and equivalent in-app/automation writes).

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
string (Order event type)
Enum: "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled"

The five order lifecycle events. All are opt-in: delivered only to endpoints that list them explicitly in events — an endpoint registered without an events list receives none of them. They fire for every order write source (API, in-app, automations), never for historical import ingestion. Payload shape: OrderEventBase (plus the refund block on OrderRefundedEvent).

Value: "order.paid"
created_at
required
string <date-time>

When the event occurred.

required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "order.paid",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

order.refunded — a refund was recorded on the order Webhook

Opt-in order lifecycle event — delivered only to endpoints that list it explicitly in events. In addition to the shared order data fields, data.refund carries the refund that fired this event.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
string (Order event type)
Enum: "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled"

The five order lifecycle events. All are opt-in: delivered only to endpoints that list them explicitly in events — an endpoint registered without an events list receives none of them. They fire for every order write source (API, in-app, automations), never for historical import ingestion. Payload shape: OrderEventBase (plus the refund block on OrderRefundedEvent).

Value: "order.refunded"
created_at
required
string <date-time>

When the event occurred.

required
object

Responses

Request samples

Content type
application/json
{
  • "id": "a1b2c3d4-e5f6-0718-2930-4a5b6c7d8e9f",
  • "type": "order.refunded",
  • "created_at": "2026-07-15T09:30:00.000Z",
  • "data": {
    }
}

order.cancelled — the order was cancelled Webhook

Opt-in order lifecycle event — delivered only to endpoints that list it explicitly in events. Cancellation is the cancelled_at stamp — the financial_status in data keeps its last state (cancellation is not a financial status).

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
string (Order event type)
Enum: "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled"

The five order lifecycle events. All are opt-in: delivered only to endpoints that list them explicitly in events — an endpoint registered without an events list receives none of them. They fire for every order write source (API, in-app, automations), never for historical import ingestion. Payload shape: OrderEventBase (plus the refund block on OrderRefundedEvent).

Value: "order.cancelled"
created_at
required
string <date-time>

When the event occurred.

required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "order.cancelled",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

order.fulfilled — the order was fulfilled Webhook

Opt-in order lifecycle event — delivered only to endpoints that list it explicitly in events. Fulfillment is recorded in-app — there is no /v1 fulfillment route, so this event only ever originates from non-API writes.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
string (Order event type)
Enum: "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled"

The five order lifecycle events. All are opt-in: delivered only to endpoints that list them explicitly in events — an endpoint registered without an events list receives none of them. They fire for every order write source (API, in-app, automations), never for historical import ingestion. Payload shape: OrderEventBase (plus the refund block on OrderRefundedEvent).

Value: "order.fulfilled"
created_at
required
string <date-time>

When the event occurred.

required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "order.fulfilled",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

payment_request.created — a pay-link was minted Webhook

Opt-in pay-link lifecycle event — delivered only to endpoints that list it explicitly in events. Fires for every hosted pay-link mint (API and in-app). Hosted pay-links only: direct saved-card charges and internal dunning-recovery links never emit payment_request.* events.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
string (Payment-request event type)
Enum: "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled"

The four pay-link lifecycle events. All are opt-in: delivered only to endpoints that list them explicitly in events — an endpoint registered without an events list receives none of them. Hosted pay-links only (charge_kind: "checkout"): direct saved-card charges and internal dunning-recovery links never emit these events. Payload shape: PaymentRequestEventBase.

Value: "payment_request.created"
created_at
required
string <date-time>

When the event occurred.

required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "payment_request.created",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

payment_request.paid — the payer completed the pay-link Webhook

Opt-in pay-link lifecycle event — delivered only to endpoints that list it explicitly in events. Fires when the payment is verified with the provider, including a late completion of an already-cancelled link (a payer who was on the hosted page when it was cancelled). data.contact_payment_id links the settled payment ledger row. Test-mode completions fire too — check data.test_mode before recording revenue.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
string (Payment-request event type)
Enum: "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled"

The four pay-link lifecycle events. All are opt-in: delivered only to endpoints that list them explicitly in events — an endpoint registered without an events list receives none of them. Hosted pay-links only (charge_kind: "checkout"): direct saved-card charges and internal dunning-recovery links never emit these events. Payload shape: PaymentRequestEventBase.

Value: "payment_request.paid"
created_at
required
string <date-time>

When the event occurred.

required
object

Responses

Request samples

Content type
application/json
{
  • "id": "c3d4e5f6-0718-2930-4a5b-6c7d8e9f0a1b",
  • "type": "payment_request.paid",
  • "created_at": "2026-07-15T11:20:00.000Z",
  • "data": {
    }
}

payment_request.expired — the pay-link passed its deadline unpaid Webhook

Opt-in pay-link lifecycle event — delivered only to endpoints that list it explicitly in events. Fires when a pending link passes expires_at unpaid — from the expiry sweep, or lazily when the expired link is opened.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
string (Payment-request event type)
Enum: "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled"

The four pay-link lifecycle events. All are opt-in: delivered only to endpoints that list them explicitly in events — an endpoint registered without an events list receives none of them. Hosted pay-links only (charge_kind: "checkout"): direct saved-card charges and internal dunning-recovery links never emit these events. Payload shape: PaymentRequestEventBase.

Value: "payment_request.expired"
created_at
required
string <date-time>

When the event occurred.

required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "payment_request.expired",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

payment_request.cancelled — the pay-link was withdrawn Webhook

Opt-in pay-link lifecycle event — delivered only to endpoints that list it explicitly in events. Fires on POST /v1/payment-requests/{id}/cancel and on in-app cancels. A payer already on the hosted page can still complete after the cancel — a later payment_request.paid for the same request supersedes this event.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
string (Payment-request event type)
Enum: "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled"

The four pay-link lifecycle events. All are opt-in: delivered only to endpoints that list them explicitly in events — an endpoint registered without an events list receives none of them. Hosted pay-links only (charge_kind: "checkout"): direct saved-card charges and internal dunning-recovery links never emit these events. Payload shape: PaymentRequestEventBase.

Value: "payment_request.cancelled"
created_at
required
string <date-time>

When the event occurred.

required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "payment_request.cancelled",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

contact.created — a contact was created Webhook

Opt-in contact lifecycle event — delivered only to endpoints that list it explicitly in events. Fires post-commit at the intentional write seams (API upserts that create, in-app creates, form/lead capture, integration syncs) — CSV imports, bulk edits, and merge-internal writes are deliberately quiet. data.contact is a fixed compact projection — fetch the full row with GET /v1/contacts/{id}. The creation-time consent seed does not emit a separate contact.consent_changed — this event covers the creation.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
any
Value: "contact.created"
created_at
required
string <date-time>
required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "contact.created",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

contact.updated — a contact's fields changed Webhook

Opt-in contact lifecycle event — delivered only to endpoints that list it explicitly in events. Fires post-commit for intentional single-contact writes; bulk edits, CSV-import updates, merge-internal writes, and engine/noise writers (scoring, touch fields, tracking rollups) are deliberately quiet. data.changed_fields names what changed — the same field list the in-app Activity timeline records, including tags/groups junction keys and custom_fields.<key> entries. data.contact stays the compact core — never junction arrays; fetch the full row with GET /v1/contacts/{id}.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
any
Value: "contact.updated"
created_at
required
string <date-time>
required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "contact.updated",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

contact.deleted — a contact was deleted Webhook

Opt-in contact lifecycle event — delivered only to endpoints that list it explicitly in events. Carries last-known identifiers only (the row is gone) — key your mirror off data.contact_id. Bulk deletes emit one event per contact; contact merges are fully silent (a merge is not a deletion — the absorbed contact emits nothing).

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
any
Value: "contact.deleted"
created_at
required
string <date-time>
required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "contact.deleted",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

contact.consent_changed — a channel's consent state changed Webhook

Opt-in consent event — delivered only to endpoints that list it explicitly in events. Fires on real consent-state transitions (opt-in, unsubscribe, resubscribe) plus deliverability-driven suppress escalations, for every consent surface (API, forms, unsubscribe pages, in-app edits). Quiet by design: the contact-create consent seed (see contact.created), same-state re-assertions, double-opt-in ceremony markers, and preference-center metadata updates do not emit.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
any
Value: "contact.consent_changed"
created_at
required
string <date-time>
required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "contact.consent_changed",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

message.received — an inbound WhatsApp message arrived Webhook

Opt-in inbound-message event — delivered only to endpoints that list it explicitly in events. Real WhatsApp inbound only, exactly once per WhatsApp message (post-dedup): reactions, messages from blocked contacts, and WhatsApp coexistence echoes/history imports are all silent. Media rides as metadata only (mime_type, filename, bytes) — never presigned URLs or storage keys.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
any
Value: "message.received"
created_at
required
string <date-time>
required
object

Responses

Request samples

Content type
application/json
{
  • "id": "d4e5f607-1829-3a4b-5c6d-7e8f90123456",
  • "type": "message.received",
  • "created_at": "2026-07-16T08:12:05.000Z",
  • "data": {
    }
}

deal.created — a deal was created Webhook

Opt-in deal lifecycle event — delivered only to endpoints that list it explicitly in events. Fires for every write source — manual, API, automations, and Salesforce sync — data.source says which.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
string (Deal event type)
Enum: "deal.created" "deal.stage_changed" "deal.won" "deal.lost"

The four deal lifecycle events. All are opt-in: delivered only to endpoints that list them explicitly in events. They fire for every deal write source — manual, API, automations, and Salesforce sync — data.source says which. Payload shape: DealEventBase.

Value: "deal.created"
created_at
required
string <date-time>

When the event occurred.

required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "deal.created",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

deal.stage_changed — a deal moved to another stage Webhook

Opt-in deal lifecycle event — delivered only to endpoints that list it explicitly in events. data.from_stage_id/data.from_stage_name carry the origin stage — they are null on every other deal event.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
string (Deal event type)
Enum: "deal.created" "deal.stage_changed" "deal.won" "deal.lost"

The four deal lifecycle events. All are opt-in: delivered only to endpoints that list them explicitly in events. They fire for every deal write source — manual, API, automations, and Salesforce sync — data.source says which. Payload shape: DealEventBase.

Value: "deal.stage_changed"
created_at
required
string <date-time>

When the event occurred.

required
object

Responses

Request samples

Content type
application/json
{
  • "id": "e5f60718-293a-4b5c-6d7e-8f9012345678",
  • "type": "deal.stage_changed",
  • "created_at": "2026-07-16T09:00:00.000Z",
  • "data": {
    }
}

deal.won — a deal was marked won Webhook

Opt-in deal lifecycle event — delivered only to endpoints that list it explicitly in events. data.status is won and data.closed_at is stamped; the deal keeps its last stage.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
string (Deal event type)
Enum: "deal.created" "deal.stage_changed" "deal.won" "deal.lost"

The four deal lifecycle events. All are opt-in: delivered only to endpoints that list them explicitly in events. They fire for every deal write source — manual, API, automations, and Salesforce sync — data.source says which. Payload shape: DealEventBase.

Value: "deal.won"
created_at
required
string <date-time>

When the event occurred.

required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "deal.won",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

deal.lost — a deal was marked lost Webhook

Opt-in deal lifecycle event — delivered only to endpoints that list it explicitly in events. data.status is lost; data.lost_reason carries the stored reason (or null).

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
string (Deal event type)
Enum: "deal.created" "deal.stage_changed" "deal.won" "deal.lost"

The four deal lifecycle events. All are opt-in: delivered only to endpoints that list them explicitly in events. They fire for every deal write source — manual, API, automations, and Salesforce sync — data.source says which. Payload shape: DealEventBase.

Value: "deal.lost"
created_at
required
string <date-time>

When the event occurred.

required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "deal.lost",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

booking.created — a booking was made Webhook

Opt-in booking lifecycle event — delivered only to endpoints that list it explicitly in events. Fires for every booking source — data.source says which. booking.completed / booking.no_show deliberately do not exist as webhooks: those statuses are sweep-derived, not user actions.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
string (Booking event type)
Enum: "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned"

The four booking lifecycle events. All are opt-in: delivered only to endpoints that list them explicitly in events. booking.completed / booking.no_show deliberately do not exist as webhooks — those statuses are sweep-derived (a cron inferring the past), not user actions. Payload shape: BookingEventBase.

Value: "booking.created"
created_at
required
string <date-time>

When the event occurred.

required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "booking.created",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

booking.rescheduled — a booking moved to a new time Webhook

Opt-in booking lifecycle event — delivered only to endpoints that list it explicitly in events. data.start_at/data.end_at are the NEW times.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
string (Booking event type)
Enum: "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned"

The four booking lifecycle events. All are opt-in: delivered only to endpoints that list them explicitly in events. booking.completed / booking.no_show deliberately do not exist as webhooks — those statuses are sweep-derived (a cron inferring the past), not user actions. Payload shape: BookingEventBase.

Value: "booking.rescheduled"
created_at
required
string <date-time>

When the event occurred.

required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "booking.rescheduled",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

booking.cancelled — a booking was cancelled Webhook

Opt-in booking lifecycle event — delivered only to endpoints that list it explicitly in events. data.cancelled_by and data.cancel_reason carry who cancelled and why (when recorded).

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
string (Booking event type)
Enum: "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned"

The four booking lifecycle events. All are opt-in: delivered only to endpoints that list them explicitly in events. booking.completed / booking.no_show deliberately do not exist as webhooks — those statuses are sweep-derived (a cron inferring the past), not user actions. Payload shape: BookingEventBase.

Value: "booking.cancelled"
created_at
required
string <date-time>

When the event occurred.

required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "booking.cancelled",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

booking.reassigned — the booking's host changed Webhook

Opt-in booking lifecycle event — delivered only to endpoints that list it explicitly in events. data.previous_host_name carries the outgoing host — it is null on every other booking event.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
string (Booking event type)
Enum: "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned"

The four booking lifecycle events. All are opt-in: delivered only to endpoints that list them explicitly in events. booking.completed / booking.no_show deliberately do not exist as webhooks — those statuses are sweep-derived (a cron inferring the past), not user actions. Payload shape: BookingEventBase.

Value: "booking.reassigned"
created_at
required
string <date-time>

When the event occurred.

required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "booking.reassigned",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

event.attendance.changed — an attendee's status changed Webhook

Opt-in event-attendance event — delivered only to endpoints that list it explicitly in events. ONE event type for the whole family — filter on data.status; data.previous_status carries the transition (null for fresh registrations and for set-based bulk status updates, which have no per-row prior state). Fires per attendance row at the writer seams (single edits, bulk operations, automation actions).

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
any
Value: "event.attendance.changed"
created_at
required
string <date-time>
required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "event.attendance.changed",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

form.submitted — a form was submitted Webhook

Opt-in form event — delivered only to endpoints that list it explicitly in events. ONE event type for every surface — data.origin distinguishes a standalone form embed (form), a landing-page form (landing_page), and an on-site popup (popup). Fires post-persist even when no contact was resolved (data.contact_id is then null); data.fields carries the submitted answers keyed by form field ids.

Authorizations:
bearerAuth
header Parameters
X-Otok-Event
required
string (Webhook event type)
Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

The event type (e.g. email.bounced).

X-Otok-Event-Id
required
string <uuid>

The event id (same as the payload id). Stable across retries and shared across your endpoints — use it as your dedup key.

X-Otok-Signature
required
string^t=\d+,v1=[0-9a-f]{64}$

t=<unix seconds>,v1=<hex HMAC>. The signed payload is the string "<t>" + "." + <raw request body> (the exact bytes on the wire). v1 is the lowercase hex HMAC-SHA256 of that string, keyed with the entire secret string including the whsec_ prefix. t is the unix-seconds timestamp of this delivery attempt — retries carry fresh timestamps, so you can enforce a replay window. Exactly one v1 value is sent per request. Compare with a timing-safe comparison.

Request Body schema: application/json
id
required
string <uuid>

Event id — stable across retries and shared across your endpoints. Use it as your dedup key.

type
required
any
Value: "form.submitted"
created_at
required
string <date-time>
required
object

Responses

Request samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "type": "form.submitted",
  • "created_at": "2019-08-24T14:15:22Z",
  • "data": {
    }
}

List endpoints

No parameters (max 3 rows, unpaginated). Secrets are never included.

Authorizations:
bearerAuth

Responses

Response samples

Content type
application/json
{
  • "data": [
    ]
}

Register an endpoint (secret returned once)

Maximum 3 endpoints per workspace (409 endpoint_limit_reached); the cap is enforced safely under concurrency.

The secret is returned only here, once. It cannot be retrieved again. There is no rotation endpoint: to rotate, register a new endpoint with the same URL, switch verification to the new secret, then delete the old endpoint.

Authorizations:
bearerAuth
Request Body schema: application/json
required
url
required
string [ 1 .. 2048 ] characters

http:// or https:// only. URLs pointing at private, loopback, link-local, and other reserved IP ranges are rejected (400 unsafe_url) — re-checked on every delivery attempt. Prefer https://: plain http:// is accepted but the HMAC signature is then your only integrity protection.

events
Array of strings (Webhook event type) non-empty
Items Enum: "email.delivered" "email.bounced" "email.complained" "email.failed" "email.opened" "email.clicked" "order.created" "order.paid" "order.refunded" "order.cancelled" "order.fulfilled" "payment_request.created" "payment_request.paid" "payment_request.expired" "payment_request.cancelled" "contact.created" "contact.updated" "contact.deleted" "contact.consent_changed" "message.received" "deal.created" "deal.stage_changed" "deal.won" "deal.lost" "booking.created" "booking.rescheduled" "booking.cancelled" "booking.reassigned" "event.attendance.changed" "form.submitted"

Event types to receive. Must be non-empty when present. Omitted → the three delivery events (email.delivered, email.bounced, email.complained) — every other family is opt-in and must be listed explicitly: the engagement events email.opened/email.clicked, the five order.* lifecycle events, the four payment_request.* lifecycle events, the four contact lifecycle + consent events (contact.created, contact.updated, contact.deleted, contact.consent_changed), the inbound message event (message.received), the four deal.* lifecycle events, the four booking.* lifecycle events, the event-attendance event (event.attendance.changed), and the form submission event (form.submitted). A pre-existing registration never starts receiving a new family unasked. email.failed is deprecated: still accepted when listed explicitly (the registration succeeds and echoes it in events), but it is never delivered.

Responses

Request samples

Content type
application/json
{
  • "url": "string",
  • "events": [
    ]
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "url": "string",
  • "events": [
    ],
  • "is_active": true,
  • "created_at": "2019-08-24T14:15:22Z",
  • "secret": "string"
}

Delete an endpoint

Deliveries stop immediately; anything still queued for the deleted endpoint is dropped.

Authorizations:
bearerAuth
path Parameters
id
required
string

Endpoint id. Unlike other routes, a malformed (non-UUID) id returns 404, not 400 — deliberately indistinguishable from an unknown id.

Responses

Response samples

Content type
application/json
{
  • "statusCode": 0,
  • "message": "string",
  • "error": "string",
  • "error_code": "string"
}

Meeting types

Read meeting types and query open slots.

List meeting types

Standard list conventions apply.

Authorizations:
bearerAuth
query Parameters
filter
string
Default: "{}"
Example: filter={"lifecycle_stage":"lead"}

String-encoded JSON object. Invalid JSON → 400 "Invalid filter: must be valid JSON"; a non-object → 400 "Invalid filter: must be a JSON object". Any workspace_id in the filter is ignored and replaced with your authenticated workspace.

Grammar: exact match ({"lifecycle_stage": "lead"}; array values match any member), $where (nested condition tree, max 100 nodes, max 200 items per array value), and $gt (the only top-level comparison shortcut). Contacts additionally support $jsonb_contains, $event_attendance, and virtual consent fields. Note: the full set of $where operators is not exhaustively enumerated in the public docs — see the Getting Started guide for the documented examples.

Filter values are type-checked against the target field before the query runs — a mistyped value returns 400 Invalid filter value for "<field>": … (per field kind: not a date / not a UUID / must be one of / not a number / not a boolean). Date-only strings ("2026-01-01"), numeric strings ("125"), and "true"/"false" strings are accepted and coerced. Substring operators (contains, not_contains, starts_with, ends_with) in $where trees are rejected on non-text fields (400 Invalid filter on "<field>": substring operators (contains, starts_with, ends_with) only apply to text fields).

sort
string
Default: "-created_at"

Field name; - prefix for descending.

limit
integer [ 0 .. 500 ]
Default: 50

Hard cap 500. Non-integer or negative → 400.

offset
integer >= 0
Default: 0

Non-integer or negative → 400.

search
string

Free-text search (fields vary per resource).

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "total": 0,
  • "limit": 0,
  • "offset": 0
}

Get one meeting type

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "name": "string",
  • "slug": "string",
  • "description": "string",
  • "duration_minutes": 0,
  • "location_type": "string",
  • "is_active": true,
  • "scheduling_kind": "string",
  • "host": {
    },
  • "hosts": [
    ],
  • "created_at": "2019-08-24T14:15:22Z"
}

Get open slots for a date range

Returns the open start instants for the meeting type — the same availability the public booking page offers. Slot instants are UTC ISO timestamps; timezone is the host schedule's IANA zone.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

query Parameters
from
required
string <date-time>

Range start (inclusive), ISO 8601.

to
required
string <date-time>

Range end (exclusive); must be after from; range may not exceed 62 days.

Responses

Response samples

Content type
application/json
{
  • "meeting_type_id": "45f6cfc0-931f-46e2-aa0a-27c7890f7f67",
  • "timezone": "string",
  • "duration_minutes": 0,
  • "slots": [
    ]
}

Get website-embed material

Everything needed to put the booking calendar on your own website: the hosted booking page URL, the workspace's publishable embed key, and a ready-to-paste snippet. embed_key (bk_…) is safe in page HTML by design and is NOT the secret API key — rotation, the origin allowlist, and the embed on/off switch live in the oToK app under Settings → Booking. Bookings made through the embed carry source: "embed".

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "workspace_ref": "string",
  • "slug": "string",
  • "embed_key": "string",
  • "page_url": "http://example.com",
  • "snippet_html": "string"
}

Bookings

Booking lifecycle — create, cancel, reschedule, reassign host.

List bookings

All filters optional and combined with AND. Malformed values → 400.

Authorizations:
bearerAuth
query Parameters
status
string
Enum: "confirmed" "cancelled" "completed" "no_show"
meeting_type_id
string <uuid>
from
string <date-time>

start_at >= from (ISO 8601).

to
string <date-time>

start_at <= to (ISO 8601).

sort
string
Default: "-start_at"

Default -start_at; - prefix for descending.

limit
integer [ 0 .. 500 ]
Default: 50

Hard cap 500. Non-integer or negative → 400.

offset
integer >= 0
Default: 0

Non-integer or negative → 400.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "total": 0,
  • "limit": 0,
  • "offset": 0
}

Create a booking

Books a slot. The slot must be open at booking time; slot-taking is race-safe. The usual booking side effects run (confirmation email, calendar/Zoom setup where configured).

Idempotency is server-derived — you do not send a key. A double submit of the same slot + meeting type + contact (+ pinned host, when used) returns the existing confirmed booking as a success instead of failing or double-booking — still 201, with duplicate: true (a fresh create answers duplicate: false). For round-robin types, a pinned create can never silently replay onto a different host than the one pinned.

Authorizations:
bearerAuth
Request Body schema: application/json
required
meeting_type_id
required
string <uuid>

Must be active.

start_at
required
string <date-time>

Must be an open slot (use the slots endpoint).

timezone
required
string

Valid IANA zone (e.g. Europe/Berlin) — recorded as the invitee's timezone.

contact_id
string <uuid>

Existing contact.

object

Upserted into contacts by phone/email; the email receives the confirmation and manage link.

notes
string <= 2000 characters
host_user_id
string <uuid>

Round-robin meeting types only: pin the booking to this pool host (must be an active pool member with the slot free). Pinned bookings still count toward round-robin fairness.

Responses

Request samples

Content type
application/json
{
  • "meeting_type_id": "45f6cfc0-931f-46e2-aa0a-27c7890f7f67",
  • "start_at": "2019-08-24T14:15:22Z",
  • "timezone": "string",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "invitee": {
    },
  • "notes": "string",
  • "host_user_id": "30451f6b-8801-4963-900e-e86af0f08607"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "meeting_type_id": "45f6cfc0-931f-46e2-aa0a-27c7890f7f67",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "host_user_id": "30451f6b-8801-4963-900e-e86af0f08607",
  • "hosts": [
    ],
  • "status": "confirmed",
  • "start_at": "2019-08-24T14:15:22Z",
  • "end_at": "2019-08-24T14:15:22Z",
  • "host_timezone": "string",
  • "invitee_timezone": "string",
  • "invitee_name": "string",
  • "invitee_email": "string",
  • "invitee_phone": "string",
  • "join_url": "string",
  • "notes": "string",
  • "source": "string",
  • "cancelled_at": "2019-08-24T14:15:22Z",
  • "cancelled_by": "string",
  • "cancellation_reason": "string",
  • "rescheduled_at": "2019-08-24T14:15:22Z",
  • "previous_start_at": "2019-08-24T14:15:22Z",
  • "reschedule_count": 0,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z",
  • "duplicate": true
}

Get one booking

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Responses

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "meeting_type_id": "45f6cfc0-931f-46e2-aa0a-27c7890f7f67",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "host_user_id": "30451f6b-8801-4963-900e-e86af0f08607",
  • "hosts": [
    ],
  • "status": "confirmed",
  • "start_at": "2019-08-24T14:15:22Z",
  • "end_at": "2019-08-24T14:15:22Z",
  • "host_timezone": "string",
  • "invitee_timezone": "string",
  • "invitee_name": "string",
  • "invitee_email": "string",
  • "invitee_phone": "string",
  • "join_url": "string",
  • "notes": "string",
  • "source": "string",
  • "cancelled_at": "2019-08-24T14:15:22Z",
  • "cancelled_by": "string",
  • "cancellation_reason": "string",
  • "rescheduled_at": "2019-08-24T14:15:22Z",
  • "previous_start_at": "2019-08-24T14:15:22Z",
  • "reschedule_count": 0,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Cancel a booking

API cancellations are attributed to the host side.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Request Body schema: application/json
optional
reason
string <= 1000 characters

Responses

Request samples

Content type
application/json
{
  • "reason": "string"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "meeting_type_id": "45f6cfc0-931f-46e2-aa0a-27c7890f7f67",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "host_user_id": "30451f6b-8801-4963-900e-e86af0f08607",
  • "hosts": [
    ],
  • "status": "confirmed",
  • "start_at": "2019-08-24T14:15:22Z",
  • "end_at": "2019-08-24T14:15:22Z",
  • "host_timezone": "string",
  • "invitee_timezone": "string",
  • "invitee_name": "string",
  • "invitee_email": "string",
  • "invitee_phone": "string",
  • "join_url": "string",
  • "notes": "string",
  • "source": "string",
  • "cancelled_at": "2019-08-24T14:15:22Z",
  • "cancelled_by": "string",
  • "cancellation_reason": "string",
  • "rescheduled_at": "2019-08-24T14:15:22Z",
  • "previous_start_at": "2019-08-24T14:15:22Z",
  • "reschedule_count": 0,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Move a booking to a new slot

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Request Body schema: application/json
required
start_at
required
string <date-time>

ISO 8601 — a new open slot.

timezone
string <= 64 characters

Updates the invitee timezone; omitted keeps the existing one.

Responses

Request samples

Content type
application/json
{
  • "start_at": "2019-08-24T14:15:22Z",
  • "timezone": "string"
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "meeting_type_id": "45f6cfc0-931f-46e2-aa0a-27c7890f7f67",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "host_user_id": "30451f6b-8801-4963-900e-e86af0f08607",
  • "hosts": [
    ],
  • "status": "confirmed",
  • "start_at": "2019-08-24T14:15:22Z",
  • "end_at": "2019-08-24T14:15:22Z",
  • "host_timezone": "string",
  • "invitee_timezone": "string",
  • "invitee_name": "string",
  • "invitee_email": "string",
  • "invitee_phone": "string",
  • "join_url": "string",
  • "notes": "string",
  • "source": "string",
  • "cancelled_at": "2019-08-24T14:15:22Z",
  • "cancelled_by": "string",
  • "cancellation_reason": "string",
  • "rescheduled_at": "2019-08-24T14:15:22Z",
  • "previous_start_at": "2019-08-24T14:15:22Z",
  • "reschedule_count": 0,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Change the assigned host

Change which host owns a confirmed booking. Reassignment is pre-start only and not available for collective meeting types (where every pool host attends).

409 handling: HOST_UNAVAILABLE is overridable by repeating the request with "force": true; SLOT_TAKEN (a genuine conflicting booking) is never overridable, even with force; BOOKING_MODIFIED means the booking changed concurrently — re-read and retry.

Authorizations:
bearerAuth
path Parameters
id
required
string <uuid>

Record UUID. A non-UUID value returns 400.

Request Body schema: application/json
optional
user_id
string <uuid>

Any active workspace member. Omitted → automatic round-robin re-pick excluding the current host (round-robin types only).

reason
string <= 1000 characters

Recorded on the assignment history.

force
boolean

Override host-availability failures. Must be literally true.

Value: true

Responses

Request samples

Content type
application/json
{
  • "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5",
  • "reason": "string",
  • "force": true
}

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "meeting_type_id": "45f6cfc0-931f-46e2-aa0a-27c7890f7f67",
  • "contact_id": "f245e2a3-e141-4d41-912c-b1c1f5bd5598",
  • "host_user_id": "30451f6b-8801-4963-900e-e86af0f08607",
  • "hosts": [
    ],
  • "status": "confirmed",
  • "start_at": "2019-08-24T14:15:22Z",
  • "end_at": "2019-08-24T14:15:22Z",
  • "host_timezone": "string",
  • "invitee_timezone": "string",
  • "invitee_name": "string",
  • "invitee_email": "string",
  • "invitee_phone": "string",
  • "join_url": "string",
  • "notes": "string",
  • "source": "string",
  • "cancelled_at": "2019-08-24T14:15:22Z",
  • "cancelled_by": "string",
  • "cancellation_reason": "string",
  • "rescheduled_at": "2019-08-24T14:15:22Z",
  • "previous_start_at": "2019-08-24T14:15:22Z",
  • "reschedule_count": 0,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}