Skip to main content

Official Python SDK for the oToK marketing platform public API (/v1) — contacts, deals, orders, transactional email, webhooks, and a high-level e-commerce layer.

Project description

otok (Python)

Official Python SDK for the oToK marketing platform public API (/v1).

Gives bespoke websites and e-commerce stores out-of-the-box integration with oToK: contact upserts, sales deals, e-commerce orders, transactional email, broadcast email campaigns, newsletters, WhatsApp templates, campaigns, payments, hosted pay-links, bookings — plus signed-webhook verification and a high-level e-commerce layer that is safe to retry by design.

  • Python 3.9+, zero runtime dependencies (stdlib urllib behind an injectable transport)
  • Full type hints (py.typed) derived from the real API contract
  • Automatic retries with exponential backoff + jitter on 429/5xx (honors Retry-After), plus transient network errors for requests that are safe to replay
  • Auto-paginating generators (for contact in client.contacts.iter(): ...)
  • Constant-time webhook signature verification

Install

pip install otok

Quickstart

Create an API key in Settings → Developers → API keys in your oToK workspace (keys look like otok_live_… and are shown once). All requests go to the oToK API at https://app.otok.io/api.

import os

from otok import OtokClient

client = OtokClient(api_key=os.environ["OTOK_API_KEY"])

Upsert a contact

POST /v1/contacts upserts by phone (canonicalized to E.164), falling back to email. tags and groups are names — missing ones are created automatically, and on upsert they are added (never removed).

contact = client.contacts.upsert(
    {
        "email": "jane@example.com",
        "phone": "+12025551234",
        "first_name": "Jane",
        "last_name": "Doe",
        "tags": ["VIP", "Newsletter"],
        "custom_fields": {"plan": "gold"},
    }
)
# contact["duplicate"]: True when an existing contact was matched and
# updated, False on a fresh create (the status is 201 either way).

Iterate a whole collection (auto-pagination)

Every paginated list endpoint has a matching iter() that returns a generator: it accepts the same filter/sort/search params as list() and fetches pages lazily until the collection is exhausted.

for contact in client.contacts.iter({"filter": {"lifecycle_stage": "customer"}}):
    print(contact["email"])

Pages are requested at each endpoint's documented limit cap — 500 for the standard lists (contacts, tags, contact groups, campaigns, templates, meeting types, bookings), 100 for deals, payments, payment requests, orders, email campaigns, and newsletters (including newsletter issues), which paginate differently. Pass a smaller limit to override the page size (a larger one is clamped to the cap); offset sets the starting position:

for deal in client.deals.iter({"status": "open", "limit": 50}):
    ...  # pages of 50 through GET /v1/deals

Contact notes

Plain-text annotations on a contact (API note payloads are text only — rich text and mentions are in-app features). list_notes returns a bare list (the endpoint is unpaginated), pinned notes first, then newest-first.

note = client.contacts.create_note(contact["id"], "Asked for a demo next week", pinned=True)
client.contacts.update_note(note["id"], body="Demo booked for Tuesday", pinned=False)
notes = client.contacts.list_notes(contact["id"])
client.contacts.delete_note(note["id"])  # -> {"success": True}

Create a deal from an order (idempotent)

external_reference maps one order to one deal — a repeat POST with the same reference updates that deal instead of creating a duplicate, so retries are always safe. The response's duplicate field tells you which happened (True = an existing deal was matched; the status is 201 either way).

pipelines = client.pipelines.list()  # map stage ids once

deal = client.deals.create(
    {
        "email": "jane@example.com",           # contact matched or created
        "title": "Order A-1001",
        "amount": 249.9,
        "currency": "USD",
        "external_reference": "order:A-1001",  # <- idempotency key
    }
)

# Later: mark it won when the order is fulfilled
client.deals.set_status(deal["id"], {"status": "won"})

Or use the high-level e-commerce layer, which does the contact upsert + idempotent deal (+ optional receipt email) in one call:

result = client.commerce.track_order(
    {
        "order_id": "A-1001",
        "customer": {"email": "jane@example.com", "name": "Jane Doe", "tags": ["Customer"]},
        "total": 249.9,
        "currency": "USD",
        "receipt": {"subject": "Your order A-1001", "html": "<p>Thanks for your order!</p>"},
    }
)
result.contact, result.deal, result.receipt

track_order is safe to call from at-least-once webhook handlers (e.g. a store's order.created event): replays converge on the same contact, deal (order:<id>), and receipt (order:<id>:receipt email idempotency key).

track_order records a sales-pipeline entry (a deal), not an order object — for real orders with line items, refunds, and financial statuses, use the Orders API (client.orders).

Orders (line items, refunds, statuses)

POST /v1/orders creates a full e-commerce order on a contact: line items, header money rollups (JSON numbers in the order's currency), a financial status (pending, paid, partially_paid, refunded, partially_refunded, voided) plus a read-only fulfillment status, an append-only refund ledger, and a separate cancellation stamp. Requires the Orders plan feature (see errors).

order = client.orders.create(
    {
        "email": "jane@example.com",        # contact matched or created
        "items": [
            {"title": "Widget", "unit_price": 170, "quantity": 2},
            {"product_sku": "SKU-1"},       # price + title derive from the catalog product
        ],
        "shipping_total": 20,
        "financial_status": "paid",         # records the payment + fires order-paid automations
        "external_reference": "shop:1001",  # <- idempotency key
    }
)

external_reference makes create an idempotent upsert: a repeat POST with the same reference updates that order instead of creating a duplicate — note, coupon_codes, placed_at, and deal_id always apply; money fields (items, currency, discount_total, shipping_total, tax_total) apply only while the order is still pending; financial_status and the contact never change on a match. Unlike the other create endpoints there is no top-level duplicate flag — both outcomes answer 201 with the full order; to distinguish, compare created_at or pre-check with client.orders.list({"external_reference": "shop:1001"}).

Status moves ride dedicated endpoints (there is no PATCH on orders):

client.orders.mark_paid(order["id"])  # records a payment on the contact
client.orders.mark_paid(order["id"], {"payment_reference": "inv-1001"})  # …or link an existing one
client.orders.cancel(order["id"])     # stamps cancelled_at; recorded revenue stands until refunded

Marking an already-paid order paid is a no-op success; orders in refund states raise a 409 with err.code == "ORDER_ILLEGAL_TRANSITION" (refund states are reachable only by recording refunds). A bad payment_reference raises typed errors too: ORDER_PAYMENT_REFERENCE_NOT_FOUND, ORDER_PAYMENT_CONTACT_MISMATCH, ORDER_PAYMENT_NOT_LINKABLE, ORDER_PAYMENT_ALREADY_LINKED.

Refunds append to the order's ledger and roll the financial status to partially_refunded/refunded:

result = client.orders.create_refund(
    order["id"],
    {"amount": 50, "reason": "Damaged in transit", "external_refund_id": "refund-77"},
)
result["duplicate"]  # True = this external_refund_id was already recorded; nothing was applied
result["order"]["refunded_total"], result["order"]["financial_status"]

external_refund_id is the refund's idempotency key. Without it refunds are not idempotent — every call appends a new refund — so supply it whenever your system can retry. Refunding requires the order to have ever been paid (400 with err.code == "ORDER_NEVER_PAID" otherwise), and cancellation doesn't block refunds (the money axis is separate).

List and iterate with dedicated filters, newest placed_at first:

for order in client.orders.iter({"status": "paid", "placed_from": "2026-07-01T00:00:00Z"}):
    ...  # pages of 100 through GET /v1/orders

Send a transactional email

Content passes through verbatim — no footer, tracking, or List-Unsubscribe injection unless you opt in. The idempotency_key is required; a repeat call returns the original send (duplicate: true) and never sends twice.

result = client.emails.send(
    {
        "to": "jane@example.com",
        "subject": "Your password reset link",
        "html": '<p>Click <a href="https://shop.example.com/reset">here</a>.</p>',
        "idempotency_key": "pwreset:user-42:2026-07-14",
        "tracking": {"opens": True, "clicks": True},  # optional, default off
        "metadata": {"user_id": "42"},                # echoed in webhook events
    }
)
# result["status"]: "sent" | "suppressed"; result["duplicate"]: bool

Receive delivery webhooks

Register an endpoint (max 3 per workspace). The whsec_… signing secret is returned once — store it.

endpoint = client.webhook_endpoints.create(
    {
        "url": "https://shop.example.com/api/otok-events",
        # Defaults to the three delivery events; engagement events are opt-in:
        "events": [
            "email.delivered",
            "email.bounced",
            "email.complained",
            "email.opened",
            "email.clicked",
        ],
    }
)
print(endpoint["secret"])  # whsec_… — shown only now

email.failed is deprecated: it is still accepted at registration (existing integrations keep working), but it never fires — a failing POST /v1/emails fails synchronously on the request itself, so handle send failures from that response.

Order lifecycle events — order.created, order.paid, order.refunded, order.cancelled, order.fulfilled — ride the same signed deliveries. They are opt-in by listing (an endpoint registered without events still defaults to the three email delivery events) and fire for every order write source (API, in-app, automations), not just API-created orders. order.refunded events additionally carry a refund block (amount, external_refund_id, reason, refunded_at).

Payment-request lifecycle events — payment_request.created, payment_request.paid, payment_request.expired, payment_request.cancelled — are opt-in by listing too (PAYMENT_REQUEST_WEBHOOK_EVENT_TYPES). They fire for hosted pay-links from every mint source (API and in-app), never for direct saved-card charges or internal dunning-recovery links. Payloads follow the order-event conventions (full field set, explicit nulls); data["test_mode"] is always present — check it before recording revenue, and treat a late payment_request.paid after a cancel as authoritative.

Events are POSTed with an X-Otok-Signature: t=<unix>,v1=<hex> header (HMAC-SHA256 of "{t}.{body}" with your secret). Failed deliveries retry for ≈16 hours. Always verify against the raw request body — parsing and re-serializing changes the bytes.

Flask

import os

from flask import Flask, request

from otok import OtokWebhookVerificationError, construct_event

app = Flask(__name__)

@app.post("/api/otok-events")
def otok_events():
    try:
        event = construct_event(
            request.get_data(),  # raw body — keep the exact bytes!
            request.headers.get("X-Otok-Signature"),
            os.environ["OTOK_WEBHOOK_SECRET"],
        )
    except OtokWebhookVerificationError:
        return "bad signature", 400
    if event["type"] == "email.bounced":
        print("bounced:", event["data"]["to"], event["data"].get("bounce_type"))
    elif event["type"] == "email.clicked":
        print("clicked:", event["data"]["url"])
    return "ok", 200  # 2xx stops retries; dedupe on event["id"]

FastAPI

import os

from fastapi import FastAPI, Request, Response

from otok import OtokWebhookVerificationError, construct_event

app = FastAPI()

@app.post("/api/otok-events")
async def otok_events(request: Request) -> Response:
    raw_body = await request.body()  # raw bytes — do not parse first
    try:
        event = construct_event(
            raw_body,
            request.headers.get("x-otok-signature"),
            os.environ["OTOK_WEBHOOK_SECRET"],
        )
    except OtokWebhookVerificationError:
        return Response(content="bad signature", status_code=400)
    # ...handle event...
    return Response(content="ok", status_code=200)

Django

import os

from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt

from otok import OtokWebhookVerificationError, construct_event

@csrf_exempt  # webhooks carry no CSRF token — the HMAC signature authenticates
def otok_events(request):
    try:
        event = construct_event(
            request.body,  # raw bytes — do not parse first
            request.headers.get("X-Otok-Signature"),
            os.environ["OTOK_WEBHOOK_SECRET"],
        )
    except OtokWebhookVerificationError:
        return HttpResponse("bad signature", status=400)
    # ...handle event...
    return HttpResponse("ok", status=200)

You can also call verify_webhook_signature(payload, header, secret, tolerance_seconds=300) directly when you only need a boolean (default timestamp tolerance: 5 minutes).

API coverage

Namespace Endpoints
client.contacts GET/POST /v1/contacts, GET/PATCH /v1/contacts/:id (POST = upsert by phone/email); consent: GET /v1/contacts/:id/consent, PUT /v1/contacts/:id/consent/:channel; documents: GET /v1/contacts/:id/documents (Payments feature); notes: GET/POST /v1/contacts/:id/notes, PATCH/DELETE /v1/notes/:id
client.tags GET/POST /v1/tags, GET/PATCH /v1/tags/:id
client.contact_groups GET/POST /v1/contact-groups, GET/PATCH /v1/contact-groups/:id
client.pipelines GET /v1/pipelines (with ordered stages)
client.deals GET/POST /v1/deals, GET/PATCH /v1/deals/:id, POST /v1/deals/:id/stage, POST /v1/deals/:id/status
client.products GET/POST /v1/products, GET/PATCH /v1/products/:id — the product catalog shared by deals and payments (POST = idempotent upsert by external_id; no delete — deactivate with is_active: False)
client.emails POST /v1/emails (transactional, idempotent)
client.suppressions GET/POST /v1/suppressions, DELETE /v1/suppressions/:id — the email suppression list (email_marketing feature; add is idempotent, and deliberately independent of consent)
client.audiences GET /v1/audiences — read-only discovery of saved audiences (the audience_id targeting selectors; rows carry the advisory last_count size cache but never the stored definition)
client.sender_profiles GET /v1/sender-profiles — read-only discovery of email from-identities (the sender_profile_id selectors) with the verified send-readiness signal (email_marketing feature)
client.email_campaigns GET/POST /v1/email-campaigns, GET/PATCH /v1/email-campaigns/:id, GET …/estimate, POST …/send, POST …/schedule, POST …/unschedule — broadcast email campaigns authored through the shared content contract (email_marketing feature; POST = idempotent upsert by external_reference)
client.newsletters GET/POST /v1/newsletters, GET /v1/newsletters/:id; issues: GET/POST /v1/newsletters/:id/issues, GET/PATCH/DELETE /v1/newsletter-issues/:id, POST …/publish, POST …/schedule, POST …/unschedule (newsletters feature; issue POST = idempotent upsert by external_reference)
client.campaigns GET/POST /v1/campaigns, GET/PATCH /v1/campaigns/:id, POST /v1/campaigns/:id/execute
client.templates GET /v1/templates, GET /v1/templates/:id, POST /v1/templates/:id/send (WhatsApp)
client.payments GET/POST /v1/payments, GET/PATCH /v1/payments/:id, POST …/cancel, POST …/entries/:entryId/mark, POST …/refund
client.payment_requests GET/POST /v1/payment-requests, GET /v1/payment-requests/:id, POST …/cancel — hosted pay-links (workspace_payments feature; create is not idempotent)
client.orders GET/POST /v1/orders, GET /v1/orders/:id, POST …/refunds, POST …/mark-paid, POST …/cancel
client.meeting_types GET /v1/meeting-types, GET /v1/meeting-types/:id, GET /v1/meeting-types/:id/slots, GET /v1/meeting-types/:id/embed
client.bookings GET/POST /v1/bookings, GET /v1/bookings/:id, POST …/cancel, POST …/reschedule, POST …/reassign
client.webhook_endpoints GET/POST /v1/webhook-endpoints, DELETE /v1/webhook-endpoints/:id
client.commerce High-level: identify_customer(customer), track_order(order)

Request/response field names match the wire contract (snake_case) exactly, so the interactive API reference at https://app.otok.io/api/v1/docs applies 1:1. The commerce layer accepts friendlier flat dicts and maps them for you.

Every namespace with a paginated list() (contacts, tags, contact groups, deals, products, suppressions, audiences, sender profiles, email campaigns, newsletters, campaigns, templates, payments, payment requests, orders, meeting types, bookings) also has an auto-paginating iter() — plus client.newsletters.iter_issues(newsletter_id) for one newsletter's issues. See Iterate a whole collection.

Errors, timeouts, retries

  • Non-2xx responses raise OtokAPIError with status, code (machine-readable, when present), and the parsed body. code comes from the {"error": {"code", "message"}} envelope (e.g. endpoint_not_found, SLOT_TAKEN, campaign_not_found, campaign_not_scheduled) or from a top-level error_code field (e.g. FEATURE_NOT_INCLUDED_IN_PLAN, CONTACT_MERGE_REQUIRED). Key your handling on status + code, never on the message text.

  • Plan-feature gating (403): the endpoint groups that mirror plan-gated product areas — deals + pipelines (Deals), payments (client.payments + client.contacts.list_documents), payment requests (client.payment_requests, gated by the separate workspace_payments feature), orders (Orders), campaigns (Campaigns), bookings + meeting types (Booking), email campaigns + suppressions + sender profiles (client.email_campaigns + client.suppressions + client.sender_profiles, all gated by email_marketing), newsletters (client.newsletters, gated by newsletters) — answer every call with a 403 with err.code == "FEATURE_NOT_INCLUDED_IN_PLAN" when the workspace's plan lacks the feature. Contacts (including consent, except the documents sub-route), tags, contact groups, templates, products, audiences, notes, emails, and webhook endpoints are not feature-gated.

  • Invalid filter values (400): list-endpoint filter values are type-checked against the target field — a mistyped date/UUID/enum/number/boolean returns a 400 with a descriptive message (e.g. Invalid filter value for "created_at": "not-a-date" is not a date) instead of a server error.

  • Duplicate names (409): creating or renaming a tag / contact group to a name that already exists in the workspace (case-insensitive) returns a 409 (A tag with this name already exists).

  • Contact identity conflicts (409): PATCH /v1/contacts/:id now behaves like POST /v1/contacts when a phone/email change collides with an identifier another contact holds (or previously held): the write is not applied — a merge request is parked for review in oToK and the 409 raises with err.code == "CONTACT_MERGE_REQUIRED"; its merge_request_id is on err.body. Non-identity fields sent in the same PATCH are held on the merge request and applied when it is resolved.

  • Campaign execute uses real status codes: POST /v1/campaigns/:id/execute answers 200 with {"success": true, …} when queued, and raises OtokAPIError otherwise — 404 (code == "campaign_not_found") or 409 (code == "campaign_not_scheduled"; campaigns created without an explicit status default to draft, so set status: "scheduled" before executing). It no longer answers 201 with success: false in the body.

  • Slow requests raise OtokTimeoutError — with the default urllib transport the timeout option (default 30 s) bounds each socket operation (connect, each read) rather than a whole attempt's wall-clock time.

  • Redirects are never followed: a 3xx comes back as an OtokAPIError, so the bearer API key is never re-sent to a redirect target.

  • 429 and 5xx responses are retried up to max_retries times (default 2) with exponential backoff + full jitter, honoring the Retry-After header (both delta-seconds and HTTP-date forms). This applies to all requests: the server answered, so the retry semantics are unchanged from v0.1.

  • Transient network errors are retried too — but only when replaying is safe. Connection resets/refusals (ConnectionError), DNS failures (socket.gaierror), socket timeouts (TimeoutError, and the SDK's own OtokTimeoutError) — raised directly or wrapped in a urllib.error.URLError — share the same bounded backoff schedule (max_retries, exponential + full jitter) if and only if the request is:

    • a safe method (GET/HEAD), or
    • a write carrying its own idempotency key: a body with a non-empty idempotency_key (client.emails.send), external_reference (client.deals.create, client.payments.create, client.orders.create, client.email_campaigns.create, client.newsletters.create_issue), or external_refund_id (client.orders.create_refund).

    Any other write (contact upserts, tag/group/campaign writes, bookings, stage moves, ...) is never network-retried — a network error is ambiguous (the request may have reached the server), so the error is raised for you to handle. In particular, client.payment_requests.create is never auto-retried: the endpoint has no idempotency key at all, and a replay would mint a second, independently payable link — check client.payment_requests.list() before minting again after a failure. To make such flows retry-safe, use the idempotent surfaces (external_reference, idempotency_key, client.commerce.track_order) or retry at the call site.

  • Rate limits are enforced per API key (default 100 requests/min; POST /v1/emails allows 300/min).

from otok import OtokAPIError

try:
    client.bookings.create({...})
except OtokAPIError as err:
    if err.code == "SLOT_TAKEN":
        ...  # offer another slot
    elif err.code == "FEATURE_NOT_INCLUDED_IN_PLAN":
        ...  # the workspace's plan lacks the Booking feature
    else:
        raise

Examples

Runnable scripts live in examples/:

Development

pip install -e ".[dev]"
pytest
ruff check .
mypy

Versioning & scope (v0.7)

Covered: the e-commerce path end to end (contacts + consent + notes + financial documents, tags/groups, pipelines/deals, the product catalog, orders with refunds, transactional email + suppressions + webhooks, payments, payment requests), the email-marketing surface (broadcast email campaigns + newsletters, authored through the shared content contract), plus campaigns, WhatsApp templates, bookings, auto-paginating iterators on every paginated list endpoint, and bounded retries for transient network errors on safe/idempotency-keyed requests. Sync client only; not covered yet: an async client and list-endpoint $where advanced filter helpers — planned for a later release.

New in v0.7.0:

  • client.email_campaigns — the Email Campaigns API (/v1/email-campaigns, requires the email_marketing plan feature): list/iter (pages of 100, like deals/payments), get, create (idempotent upsert via external_referenceduplicate: True on a replay; write responses carry a compile: {ok, errors, warnings} envelope), update, estimate ({"estimated_recipients": n}), send (a launch-gate failure raises a 422 with err.code == "launch_failed" and campaign_status on the error body), schedule, and unschedule
  • client.newsletters — the Newsletters API (/v1/newsletters + /v1/newsletter-issues, requires the newsletters plan feature): list/iter, create, get, plus issues — list_issues/iter_issues, create_issue (idempotent upsert via external_reference), get_issue, update_issue, delete_issue (never-published issues only), publish_issue, schedule_issue, and unschedule_issue
  • The shared content contract types: an optional direction plus exactly one of markdown (with ::button[Label](url) / ::snippet[name-or-uuid] directives and [[…]] variable tokens), blocks (typed block array), or design_json (raw editor document)
  • Transient-network-error retries automatically cover the new external_reference writes (client.email_campaigns.create, client.newsletters.create_issue)
  • client.audiences / client.sender_profiles — read-only targeting-selector discovery (GET /v1/audiences, GET /v1/sender-profiles): list/iter (pages of 100, like deals/payments). Audience rows carry the advisory last_count size cache but never the stored definition (optional kind filter — an unknown value raises a 400); sender-profile rows carry the composed from_email and the verified send-readiness signal. Sender profiles require the email_marketing plan feature; audiences need only API access

New in v0.6.0:

  • client.meeting_types.embed(meeting_type_id) — website-embed material for a meeting type (GET /v1/meeting-types/:id/embed, requires the booking plan feature): 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

New in v0.5.0:

  • client.contacts.get_consent(contact_id) / client.contacts.set_consent(contact_id, channel, params) — per-channel marketing consent (whatsapp/email): read the stored decision + provider-owned deliverability (email adds the composed send-time suppressed verdict), and record subscribed/unsubscribed decisions with provenance. Subscribing a channel with a spam complaint on record raises a 409 with err.code == "consent_sticky_complained"
  • client.products — the Products API (/v1/products): list/iter (standard pages of 500), get, create (idempotent upsert via external_id — the response carries duplicate: True on a match; 409 product_conflict on a sku/external_id clash), and update (no delete — deactivate with is_active: False)
  • client.suppressions — the Suppressions API (/v1/suppressions, requires the email_marketing plan feature): list/iter, create (idempotent add — duplicate: True when the address was already suppressed), and delete. Suppression is deliberately independent of consent: adding never unsubscribes a contact, removing never resubscribes one
  • Fifteen new webhook event types across six opt-in families — contact lifecycle + consent (contact.created, contact.updated, contact.deleted, contact.consent_changed), inbound messages (message.received — real WhatsApp inbound only; media as metadata, never URLs), deals (deal.created, deal.stage_changed, deal.won, deal.lost), bookings (booking.created, booking.rescheduled, booking.cancelled, booking.reassigned), event attendance (event.attendance.changed), and form submissions (form.submitted) — registrable on webhook endpoints (opt-in by listing; CONTACT_WEBHOOK_EVENT_TYPES, MESSAGE_WEBHOOK_EVENT_TYPES, DEAL_WEBHOOK_EVENT_TYPES, BOOKING_WEBHOOK_EVENT_TYPES, EVENT_ATTENDANCE_WEBHOOK_EVENT_TYPES, FORM_WEBHOOK_EVENT_TYPES) and typed as inbound events for construct_event. The default subscription is unchanged

New in v0.4.0:

  • client.payment_requests — the Payment Requests API (/v1/payment-requests): list/iter (pages of 100, like deals/payments; unknown status filters 400), get, create (mints a hosted pay-link through the workspace's own connected provider — no idempotency key exists on this resource, so create is never auto-retried on network errors; a repeat POST mints a second payable link), and cancel (CAS on pending; 409 on final rows and TOKEN_REQUEST_NOT_CANCELLABLE on saved-card charge rows). Requires the workspace_payments plan feature — distinct from the payments ledger gate
  • The four payment_request.* webhook event types (payment_request.created, payment_request.paid, payment_request.expired, payment_request.cancelled) — registrable on webhook endpoints (opt-in by listing; PAYMENT_REQUEST_WEBHOOK_EVENT_TYPES) and typed as inbound events for construct_event
  • client.contacts.list_documents(contact_id, live=...)GET /v1/contacts/:id/documents: a contact's invoices/receipts/credit documents aggregated from stored pointers, with an opt-in live provider lookup (requires the Payments feature)
  • Payments: create/update accept the recurring-plan vat_mode + vat_rate pair and a metadata object (≤2048 bytes serialized; replace-on-write, None clears on update); typings document the new payment/entry response fields (dunning state, stored VAT pair, refund/credit-document fields)

New in v0.3.0:

  • client.orders — the Orders API (/v1/orders): list/iter (pages of 100, like deals/payments), get, create (idempotent upsert via external_reference — note: this endpoint returns no top-level duplicate flag), create_refund ({duplicate, order} response; idempotent per external_refund_id — keyless refunds append on every call), mark_paid (optionally linking an existing payment via payment_reference), and cancel
  • The five order.* webhook event types (order.created, order.paid, order.refunded, order.cancelled, order.fulfilled) — registrable on webhook endpoints (opt-in by listing; ORDER_WEBHOOK_EVENT_TYPES) and typed as inbound events for construct_event
  • Transient-network-error retries now also cover writes keyed by external_refund_id (client.orders.create_refund)

New in v0.2.0:

  • iter() generators on all paginated list endpoints, honoring each resource's documented page-size cap (500 standard; 100 for deals/payments)
  • Transient network errors (connection reset/refused, DNS failure, socket timeout) now retry with the existing bounded backoff — GET/HEAD and idempotency-keyed writes only; other writes still surface the error immediately

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

otok-0.7.0.tar.gz (87.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

otok-0.7.0-py3-none-any.whl (60.4 kB view details)

Uploaded Python 3

File details

Details for the file otok-0.7.0.tar.gz.

File metadata

  • Download URL: otok-0.7.0.tar.gz
  • Upload date:
  • Size: 87.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for otok-0.7.0.tar.gz
Algorithm Hash digest
SHA256 41d07b282806c6e8f14aaf4e5efc2b45b63d8639714b6e1fe76998fa20706512
MD5 fa304866d5b7ec3f41e33ec39b511ba8
BLAKE2b-256 c4bc368ebf15a25e06c3f4493a2bffd22cbe1646c0005858f0ec9f2470b2beb8

See more details on using hashes here.

Provenance

The following attestation bundles were made for otok-0.7.0.tar.gz:

Publisher: release-sdk-python.yml on SlikkDev/otok-api

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file otok-0.7.0-py3-none-any.whl.

File metadata

  • Download URL: otok-0.7.0-py3-none-any.whl
  • Upload date:
  • Size: 60.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for otok-0.7.0-py3-none-any.whl
Algorithm Hash digest
SHA256 40beb0117c9aed589ea19ece772341800e9fb59687fc3f66a55ecc90f7163a63
MD5 0c6340ca76587f1c725b2f4e8fa0728a
BLAKE2b-256 bc030ed221eae61426feb5e68a9206f5b545954afbdf2092eebac8d8b6882206

See more details on using hashes here.

Provenance

The following attestation bundles were made for otok-0.7.0-py3-none-any.whl:

Publisher: release-sdk-python.yml on SlikkDev/otok-api

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page