sumwerk

Docs API reference · updated September 20, 2026 · What the API is for

sumwerk API reference

Everything the dashboard shows can be pulled out, with the same numbers. With a read-and-write token you can also send what only your product knows (leads and trials that are not in Stripe, product usage, owners, tags, people, notes, tasks) and manage automations. sumwerk never writes to Stripe, whatever the token.

Base address https://www.sumwerk.com/api/v1. JSON in and out. Amounts are decimal strings in major units ("49.00") with an explicit currency, dates and times ISO 8601, months in your account’s time zone. The examples below are real calls against a demo account with invented companies.

Authentication

Every call carries a token in the Authorization header:

$ curl https://www.sumwerk.com/api/v1/mrr \
    -H "Authorization: Bearer swk_9f2c…"
HTTP/2 200

Getting a token. Account → API and exports → Create token (account owners). Give it a name that says where it will live (“billing backend”, “Zapier”), and pick its scope:

Scope Can
Read only every GET
Read and write also PUT, POST, PATCH, DELETE: customers, people, notes, tasks, automations

The token (swk_…) is shown once. sumwerk stores only its SHA-256 digest, so nobody, us included, can show it to you again: if it is lost, create a new one. A token belongs to one account and sees only that account.

Sandboxes. An account can have several sandboxes (staging, one per developer): further accounts with the same team and settings and their own data. A token created in one starts with swk_test_, only sees that sandbox, and every answer carries X-Sumwerk-Mode: sandbox and X-Sumwerk-Sandbox: <name> (or X-Sumwerk-Mode: live). Same URL, same calls: use the swk_test_ token in staging and development.

Keeping it safe. Use it from a server, never from a browser or a mobile app. Give every system its own token, so that one can be revoked without stopping the others. Revoking (Account → API and exports → Revoke) works immediately. HTTPS only.

What goes wrong, and how it looks:

$ curl https://www.sumwerk.com/api/v1/mrr
HTTP/2 401
{ "error": "unauthorized" }

$ curl -X PUT https://www.sumwerk.com/api/v1/customers/org_7001 \
    -H "Authorization: Bearer swk_readonly…" -H "Content-Type: application/json" \
    -d '{"name":"Pine Robotics"}'
HTTP/2 403
{ "error": "forbidden",
  "message": "This token is read-only. Create a read-and-write token on the account page." }

Limit: 600 requests per minute per token; beyond that 429 {"error":"rate_limited"}. Wait a minute and go on: nothing was changed.

Reading

Endpoint Parameters Returns
GET /api/v1/mrr range = 12 (default), 24, 36, all currency, time_zone, close_after_days, months[]: month, closed (final: see below), closed_at, restatements (how often it changed after closing), mrr, arr, paying_customers, arpa, net_movement, fx_effect, movements{kind: {amount, customers}}, retention{}: customer_churn_rate, gross_mrr_churn_rate, net_mrr_churn_rate (fractions of what the month started with; the running month to date), net_revenue_retention_12m, gross_revenue_retention_12m (what the customers of twelve months earlier pay at this month end, of what they paid then); null when there is nothing to measure against
GET /api/v1/restatements month = 2026-03 (optional) restatements[], newest first: id, month (null: all closed months at once, after a change of reporting currency or time zone), detected_at, reason, currency, mrr_before, mrr_after, mrr_difference, paying_customers_before, paying_customers_after, movements_before{}, movements_after{}, customers[] (customer_id, name, mrr_before, mrr_after; the 50 largest)
GET /api/v1/reconciliation   the latest self-check against Stripe’s invoices: checked_at, reconciled, compared, agreed, not_comparable{reason: count}, differences[] (subscription_id, invoice_id, customer_id, name, currency, sumwerk_monthly, stripe_invoice_monthly)
GET /api/v1/risk-rules   what “at risk” means for the account: unpaid_after_days, high_after_days, cancellation_severity, failed_card_severity, late_bank_transfer_severity, and signals[], your own rules: id, name, severity, conditions[] (field, op, value; the fields and operators of automations, listed as condition_fields and condition_ops), in_words. Writing: see below
GET /api/v1/movements from, to (ISO dates, inclusive, account time zone), kind, customer (Stripe id) movements[]: date, occurred_at, kind, causes[], customer{stripe_id, name}, amount, currency, amount_reporting, mrr_before, mrr_after, sources[]
GET /api/v1/customers status = paying, trial, lead or churned (paying=true still works) customers[]: id, external_id, external_ids[] (all your ids, the first one first), stripe_ids[], stripe_id (the first), name, email, status, mrr, arr, currency, mrr_reporting (in the account’s reporting currency, the sort order), customer_since, churned_at, trial_started_at, trial_ends_at, lead_created_at, owner, plan (the product of the largest subscription item, under your own plan names; who left keeps the last one), billing_interval (Monthly, Yearly, Every 6 months, …), tags[], custom{}, company, country, contacts[]
GET /api/v1/customers/:id :id is our id, any of your own ids, or a Stripe customer id one customer
GET /api/v1/segments by = owner (default), plan, billing_interval, tag, country, currency or custom.<attribute> attributes[] (your custom attributes with detected type: text, number, yes_no, date; filled, distinct), total{}, segments[]: segment, paying_customers, mrr, share, mrr_per_customer, churned_12_months, mrr_lost_12_months, churn_rate, decided_trials_12_months, converted, trial_to_paying. Numbers with many values come as ranges; the value most customers share (often 0) gets its own row
GET /api/v1/attributes · PATCH /api/v1/attributes/:key   your custom attributes: key, type (detected: text, number, yes_no, date), filled, distinct, label, description. An attribute is never created here: it appears when a value arrives (custom on a customer). PATCH sets label and description (write scope)
GET /api/v1/lifecycle range = 12 (default), 24, 36, all in_trial_now, totals{}, months[] by the month the trial started: month, new_leads, trials_started, converted, still_in_trial, not_converted, conversion_rate (of the decided trials, 0 to 1), median_days_to_convert, paying_without_trial
GET /api/v1/at-risk   total, subscriptions[] (most severe first): severity (high | medium | low), reason (cancels | unpaid | past_due | signal: one of your own at-risk rules holds and nothing else flags the subscription), signals[] (names of your own rules that hold for the customer, also next to another reason), customer{stripe_id, name}, subscription_id, mrr, currency, mrr_reporting, overdue_days, unpaid_amount, ends_on, collection_method
$ curl "https://www.sumwerk.com/api/v1/mrr?range=12" -H "Authorization: Bearer swk_9f2c…"
HTTP/2 200
{ "currency": "eur", "time_zone": "Europe/Berlin",
  "months": [
    …,
    { "month": "2026-09", "closed": false, "closed_at": null, "restatements": 0, "mrr": "36488.00", "arr": "437856.00", "paying_customers": 100,
      "arpa": "364.88", "net_movement": "5747.00", "fx_effect": "0.00",
      "movements": { "new":          { "amount": "3274.00", "customers": 8 },
                     "expansion":    { "amount": "2473.00", "customers": 7 },
                     "reactivation": { "amount": "0.00", "customers": 0 },
                     "contraction":  { "amount": "0.00", "customers": 0 },
                     "churn":        { "amount": "0.00", "customers": 0 } } } ] }

Closed months and restatements

A month closes close_after_days after it ends (5 unless changed under Account → General). closed: true means the figures are final: copy them into your own reports without re-reading them every night. If late Stripe data, a merge of two customers or a changed setting gives a closed month other figures, the month shows the new figures, its restatements counter goes up, and GET /api/v1/restatements says when, why, from what to what, and which customers were behind it. A nightly job that mirrors sumwerk needs only the open months plus the months named there.

The restatement webhook. The account’s owners get a mail when closed months were restated (unless they switch it off). A program cannot read mail, so Account → General also takes a web address of yours. Whenever closed months were restated, sumwerk sends one message there:

POST https://your-backend.example.com/webhooks/sumwerk
Content-Type: application/json
X-Sumwerk-Signature: t=1790000000,v1=5257a869e7ec…

{ "type": "month.restated", "occurred_at": "2026-10-14T06:07:31Z",
  "data": { "restatements": [
    { "id": 12, "month": "2026-08", "detected_at": "2026-10-14T06:07:31Z",
      "reason": "Customers merged: Fjord Labs into Fjord Labs AS", "currency": "eur",
      "mrr_before": "30861.00", "mrr_after": "30741.00", "mrr_difference": "-120.00",
      "customers_changed": 1 } ] } }

What it is for: your own reporting or data warehouse reads the named months again instead of everything every night; or a “catch hook” address from Zapier or Make turns it into a message in your finance channel. Check the signature as for automation webhooks (below), answer with any 2xx. The address must be https:// and publicly reachable. Leave the field empty and nothing is sent: the mail and GET /api/v1/restatements are enough for most teams.

Every movement names its cause and the Stripe references it was derived from:

$ curl "https://www.sumwerk.com/api/v1/movements?from=2026-09-01&kind=expansion" \
    -H "Authorization: Bearer swk_9f2c…"
HTTP/2 200
{ "movements": [
    { "date": "2026-09-05", "occurred_at": "2026-09-05T14:00:00Z", "kind": "expansion",
      "causes": ["price_change"],
      "customer": { "id": 4373, "external_id": "org_1076", "stripe_id": "cus_Demo00076",
                    "name": "Harbor Cloud" },
      "amount": "591.00", "currency": "eur", "amount_reporting": "591.00",
      "mrr_before": "399.00", "mrr_after": "990.00",
      "sources": ["in_demo1203", "evt_demo5203"] },
    … ] }

A customer, by your own id (a Stripe customer id or our id work the same way):

$ curl https://www.sumwerk.com/api/v1/customers/org_1103 -H "Authorization: Bearer swk_9f2c…"
HTTP/2 200
{ "id": 4400, "external_id": "org_1103", "external_ids": ["org_1103"],
  "stripe_ids": ["cus_Demo00103"], "stripe_id": "cus_Demo00103",
  "name": "Lumen Energy", "email": "billing@lumenenergy.example", "company": "Lumen Energy",
  "country": "NL", "status": "paying",
  "mrr": "990.00", "arr": "11880.00", "currency": "eur", "mrr_reporting": "990.00",
  "customer_since": "2026-09-03", "churned_at": null,
  "trial_started_at": "2026-08-20", "trial_ends_at": "2026-09-03", "lead_created_at": "2026-08-20",
  "owner": "jonas@nordwind.example", "tags": ["Sales led"],
  "custom": { "plan": "Scale", "seats": 33, "company_type": "In-house", "active_users_30d": 18 },
  "contacts": [ { "email": "anna@lumenenergy.example", "external_id": null, "first_name": "Clara",
                  "last_name": "Lindqvist", "title": "CTO", "phone": null, "linkedin": null } ] }

Revenue that still counts but is in doubt:

$ curl https://www.sumwerk.com/api/v1/at-risk -H "Authorization: Bearer swk_9f2c…"
HTTP/2 200
{ "reporting_currency": "eur", "total": "3369.00",
  "subscriptions": [
    { "severity": "high", "reason": "cancels",
      "customer": { "id": 4375, "external_id": "org_1078", "stripe_id": "cus_Demo00078",
                    "name": "Yarrow Media" },
      "subscription_id": "sub_Demo4375", "mrr": "990.00", "currency": "eur",
      "mrr_reporting": "990.00", "overdue_days": null, "unpaid_amount": "0.00",
      "ends_on": "2026-10-01", "collection_method": "charge_automatically" },
    … ] }

Writing: customers and contacts (token with write scope)

$ curl -X PUT https://www.sumwerk.com/api/v1/customers/org_7001 \
    -H "Authorization: Bearer swk_9f2c…" -H "Content-Type: application/json" \
    -d '{ "name": "Pine Robotics", "email": "hello@pinerobotics.example",
          "trial_started_at": "2026-09-20", "trial_ends_at": "2026-10-04",
          "owner_email": "mara@nordwind.example", "tags": ["Self serve"],
          "custom": { "company_type": "Agency", "seats": 5 } }'
HTTP/2 201
{ "id": 4412, "external_id": "org_7001", "external_ids": ["org_7001"], "stripe_ids": [],
  "name": "Pine Robotics", "email": "hello@pinerobotics.example", "status": "trial",
  "mrr": "0.00", "arr": "0.00", "currency": null,
  "trial_started_at": "2026-09-20", "trial_ends_at": "2026-10-04", "lead_created_at": "2026-09-20",
  "owner": "mara@nordwind.example", "tags": ["Self serve"],
  "custom": { "seats": 5, "company_type": "Agency" }, "contacts": [], … }

The same call again answers 200 and changes only what you send. Something that cannot be right is refused as a whole, with the reason:

$ curl -X PUT https://www.sumwerk.com/api/v1/customers/org_7001 \
    -H "Authorization: Bearer swk_9f2c…" -H "Content-Type: application/json" \
    -d '{ "owner_email": "nobody@nordwind.example" }'
HTTP/2 422
{ "error": "invalid",
  "message": "owner_email nobody@nordwind.example is not a user of this account" }

PUT /api/v1/customers/:your_id creates the customer or updates it (201 / 200). :your_id is your own id for the customer, up to you (an org id, a UUID). Only the fields you send are touched.

Field Meaning
name, email, company, country text
lead_created_at, trial_started_at, trial_ends_at ISO 8601 date or time. Status is derived: trial inside the window, paying with MRR, churned after, else lead
also_known_as other ids of yours for the same customer (max 20). A customer that already exists under one of them is updated instead of a second one being created, and :your_id becomes one more of its ids: a lead you created from a booked call as jane@acme.example is found again when you later send the organisation as PUT /customers/<org id> with "also_known_as": ["jane@acme.example"]. Every id works in every URL afterwards. If the ids belong to two different customers the call is refused (422): merge them on the customer page
owner_email a user of your sumwerk account; empty removes the owner
tags list of strings, replaces the list (max 50)
custom object of your own attributes: text, number or true/false. Merged into what is there; null removes one. Names: lowercase letters, digits, underscores (max 100)
stripe_customer_ids Stripe customers that belong to this customer. If a record already mirrors that Stripe customer, it is adopted with its whole history instead of a second record being created. Adding a second one later links it; say why with link_reason: currency_change, resubscribed, duplicate_signup, separate_trial, billing_entity_change, other

POST /api/v1/customers/:id/contacts upserts a person by email (first_name, last_name, title, phone, linkedin), or by an external_id of yours for someone without an email address (then a name is required); :id can be your id, ours or a Stripe id. Contacts come back on GET /api/v1/customers/:id as contacts[].

Notes, calls and tasks

:id is our id, any of your ids, or a Stripe customer id, as everywhere.

Endpoint What it does
GET /api/v1/customers/:id/notes notes[], newest first: id, external_id, kind (note | call), body (clean HTML), text (the same as plain text), call_duration (seconds), occurred_at, author_email, author_name, source
POST /api/v1/customers/:id/notes writes a note or logs a call: body (plain text or HTML; only formatting and links survive), kind, call_duration, occurred_at (default now), author_email, author_name. With an external_id of yours the note is created once and updated after that (201 / 200), so a sync can send it again safely
GET /api/v1/customers/:id/conversations support conversations mirrored from Plain, latest activity first: open (count), conversations[]: id, title, preview, status (todo | snoozed | done), priority, labels[], from, assignee, opened_at, last_activity_at, url
GET /api/v1/customers/:id/tasks that customer’s tasks; open=true leaves out what is done
GET /api/v1/tasks the account’s tasks by due day (max 1,000): open=true, assignee=<email>, due_before=<date>
POST /api/v1/customers/:id/tasks adds a task: title, due_on (both required), assignee_email (a user of your account), external_id (created once, updated after that)
PATCH /api/v1/tasks/:id :id is ours or your external_id. Change title, due_on, assignee_email, or tick it off with "completed": true (false opens it again)
$ curl -X POST https://www.sumwerk.com/api/v1/customers/org_7001/notes \
    -H "Authorization: Bearer swk_9f2c…" -H "Content-Type: application/json" \
    -d '{ "external_id": "call_8842", "kind": "call", "call_duration": 1260,
          "body": "Intro call. Wants to start with 5 seats.",
          "author_email": "mara@nordwind.example" }'
HTTP/2 201
{ "id": 1974, "external_id": "call_8842", "kind": "call",
  "body": "Intro call. Wants to start with 5 seats.",
  "text": "Intro call. Wants to start with 5 seats.", "call_duration": 1260,
  "occurred_at": "2026-09-20T13:22:05Z", "author_email": "mara@nordwind.example",
  "author_name": "Mara Lind", "source": "api" }

$ curl -X POST https://www.sumwerk.com/api/v1/customers/org_7001/tasks \
    -H "Authorization: Bearer swk_9f2c…" -H "Content-Type: application/json" \
    -d '{ "title": "Send the proposal", "due_on": "2026-09-22",
          "assignee_email": "mara@nordwind.example" }'
HTTP/2 201
{ "id": 5287, "external_id": null, "title": "Send the proposal", "due_on": "2026-09-22",
  "completed": false, "completed_at": null, "assignee_email": "mara@nordwind.example",
  "customer": { "id": 4412, "external_id": "org_7001", "name": "Pine Robotics" },
  "source": "api" }

$ curl -X PATCH https://www.sumwerk.com/api/v1/tasks/5287 \
    -H "Authorization: Bearer swk_9f2c…" -H "Content-Type: application/json" \
    -d '{ "completed": true }'
HTTP/2 200
{ "id": 5287, "title": "Send the proposal", "completed": true,
  "completed_at": "2026-09-20T13:22:05Z", … }

A task: id, external_id, title, due_on, completed, completed_at, assignee_email, customer{id, external_id, name}, source. An automation that wants the team to act (“new booking: reach out”) adds a task; nothing in sumwerk creates tasks by itself.

Events, automations and webhooks

Events are what happened to a customer, written once: customer.created, customer.updated (data.changed[]: field names, custom attributes as custom.<name>), trial.started, customer.became_paying, customer.churned, customer.reactivated, cancellation.scheduled, cancellation.withdrawn, payment.failed, payment.recovered, risk.signal_raised, risk.signal_cleared (a paying customer starts or stops matching one of your own at-risk rules, Account → At-risk rules; data.signals[] names them), support.conversation_opened, support.conversation_closed (with Plain connected) (the last four carry data.subscription_id, the first of each pair also mrr, severity, ends_on or overdue_days). API writes announce at once; what comes from Stripe is found by the sync (hourly, or “Sync now”). Imports and the first sync of an account are history: their events are recorded with their real date and set no automation in motion (older than 48 hours).

GET /api/v1/events?type=&customer=&after_id= lists them oldest first (500 per call): a tool that cannot receive webhooks polls with the last id it has seen.

An automation says: when trigger happens and all conditions hold, do actions in order.

Endpoint  
GET /api/v1/automations the automations with how often each ran (runs{done, skipped, failed}), plus the lists of valid triggers, condition_fields, condition_ops, action_types
POST /api/v1/automations · PATCH /api/v1/automations/:id · DELETE … create, change ("active": false switches it off), remove. An automation that cannot be read is refused with every problem named (422)
GET /api/v1/automations/:id/runs the last 200 times the automation looked at an event: status (done | skipped | failed), and per action what it did, or the condition that was not met
{ "name": "Owner and first tasks for agencies",
  "trigger": "customer.updated", "trigger_fields": ["custom.company_type"],
  "conditions": [ { "field": "owner", "op": "is_blank" },
                  { "field": "custom.company_type", "op": "eq", "value": "Agency" } ],
  "actions": [ { "type": "set_owner", "email": "tyan@yourcompany.com" },
               { "type": "add_task", "title": "Find on LinkedIn", "assignee": "owner",
                 "fallback_assignee": "james@yourcompany.com", "due_in_days": 3 },
               { "type": "webhook", "url": "https://hooks.zapier.com/hooks/catch/…" } ] }
  • trigger_fields (only with customer.updated): run only when one of these fields changed.
  • Conditions: fields owner, source (api, stripe, manual, import), status, tags, name, email, company, country, currency, mrr (reporting currency, major units), external_id, has_stripe, changed, custom.<name>; ops eq, not_eq, in, not_in, contains, is_blank, is_present, gt, lt, after, before. Text compares without regard to case. All must hold; an “or” is a second automation.
  • Actions (at most 10): add_task (title, assignee: "owner" or an email address, fallback_assignee, due_in_days, default 2), set_owner (email), add_tags (tags), set_custom (key, value), send_email, enroll_in_sequence (see Email below), webhook (url, https only). An action that fails is logged and the others still run. What an automation changes never sets an automation in motion.
  • An automation looks at an event once. Automations run in a background job, seconds after the event.
$ curl -X POST https://www.sumwerk.com/api/v1/automations \
    -H "Authorization: Bearer swk_9f2c…" -H "Content-Type: application/json" \
    -d '{ "name": "Follow up on failed payments", "trigger": "payment.failed",
          "conditions": [ { "field": "mrr", "op": "gt", "value": 300 } ],
          "actions": [ { "type": "add_task", "title": "Call about the failed payment",
                         "assignee": "owner", "fallback_assignee": "mara@nordwind.example",
                         "due_in_days": 1 } ] }'
HTTP/2 201
{ "id": 12, "name": "Follow up on failed payments", "active": true,
  "trigger": "payment.failed", "trigger_fields": [],
  "conditions": [ { "field": "mrr", "op": "gt", "value": 300 } ],
  "actions": [ { "type": "add_task", "title": "Call about the failed payment", "assignee": "owner",
                 "fallback_assignee": "mara@nordwind.example", "due_in_days": 1 } ],
  "runs": { "done": 0, "skipped": 0, "failed": 0 }, "last_run_at": null }

$ curl https://www.sumwerk.com/api/v1/automations/9/runs -H "Authorization: Bearer swk_9f2c…"
HTTP/2 200
{ "runs": [
    { "id": 28, "at": "2026-09-20T13:21:36Z", "status": "done",
      "results": [ { "ok": true, "type": "add_task",
                     "detail": "task 5284 for mara@nordwind.example, due 2026-09-21" } ],
      "event": { "id": 326, "type": "cancellation.scheduled" },
      "customer": { "id": 4315, "external_id": "org_1018", "name": "Iris Pay" } },
    … ] }

$ curl "https://www.sumwerk.com/api/v1/events?type=cancellation.scheduled&after_id=300" \
    -H "Authorization: Bearer swk_9f2c…"
HTTP/2 200
{ "events": [
    { "id": 325, "type": "cancellation.scheduled", "occurred_at": "2026-09-20T13:21:36Z",
      "data": { "subscription_id": "sub_Demo4375", "reason": "cancels", "severity": "high",
                "mrr": "990.00", "currency": "eur", "ends_on": "2026-10-01" },
      "customer": { "id": 4375, "external_id": "org_1078", "name": "Yarrow Media" } },
    … ] }

Webhooks are a POST of {id, type, occurred_at, data, automation{id, name}, customer{…}} (the customer as GET /customers/:id returns it), 5 seconds timeout, no retry yet (a failure shows in the run log). Verify before trusting: the header X-Sumwerk-Signature: t=<unix time>,v1=<hex> is the HMAC-SHA256 of <t>.<raw body> with your signing secret (Account → API and exports → Webhooks).

What your server receives, and how to check it (Ruby; any language with HMAC-SHA256 works the same way):

POST /hooks/sumwerk HTTP/1.1
Content-Type: application/json
User-Agent: sumwerk-webhooks/1
X-Sumwerk-Signature: t=1789910496,v1=5f0c…e91a

{ "id": 325, "type": "cancellation.scheduled", "occurred_at": "2026-09-20T13:21:36Z",
  "data": { "subscription_id": "sub_Demo4375", "mrr": "990.00", "ends_on": "2026-10-01", … },
  "automation": { "id": 9, "name": "Follow up on scheduled cancellations" },
  "customer": { "id": 4375, "external_id": "org_1078", "name": "Yarrow Media", … } }
timestamp, signature = request.headers["X-Sumwerk-Signature"].match(/t=(\d+),v1=(\h+)/).captures
expected = OpenSSL::HMAC.hexdigest("SHA256", ENV["SUMWERK_WEBHOOK_SECRET"], "#{timestamp}.#{request.raw_post}")
head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(expected, signature) &&
                          Time.at(timestamp.to_i) > 5.minutes.ago

At-risk rules of your own

A paying customer whose attributes look like this is flagged as at risk, next to the payment risks from Stripe (GET /api/v1/at-risk: reason: "signal", the rule’s name in signals[]). At most 10 rules, each with up to 5 conditions that must all hold. A customer who does not have the attribute never matches a comparison. Write scope.

Call Body Answer
POST /api/v1/risk-rules/signals name, severity (high | medium | low), conditions[] 201 with the rule and its id
PATCH /api/v1/risk-rules/signals/:id the same, complete 200. Other conditions take stock again: who matches already sets no automation in motion
DELETE /api/v1/risk-rules/signals/:id   204
$ curl -X POST https://www.sumwerk.com/api/v1/risk-rules/signals \
    -H "Authorization: Bearer swk_9f2c…" -H "Content-Type: application/json" \
    -d '{ "name": "No project in 30 days", "severity": "medium",
          "conditions": [ { "field": "custom.projects_created_30d", "op": "lt", "value": 1 } ] }'
HTTP/2 201
{ "id": "3fa9c1d2", "name": "No project in 30 days", "severity": "medium",
  "conditions": [ { "field": "custom.projects_created_30d", "op": "lt", "value": 1 } ],
  "in_words": "Projects created 30d is below 1" }

Operators besides the ones of automations’ conditions: gte, lte, and for dates more_than_days_ago and within_last_days (value: a number of days), for example { "field": "custom.last_login_at", "op": "more_than_days_ago", "value": 14 }. A customer who starts to match raises the event risk.signal_raised, one who stops risk.signal_cleared.

Email: templates, sending, sequences

Endpoint  
GET/POST /api/v1/email_templates, GET/PATCH/DELETE /api/v1/email_templates/:id name, subject, body (rich text). Merge fields (merge_fields[] in the list): {{ contact.first_name }}, {{ customer.name }}, {{ user.name }}, …, each with an optional fallback: {{ contact.first_name, fallback=there }}. An unknown field is refused
POST /api/v1/customers/:id/emails sends one email: template_id, or subject + body; to = the address of one of the customer’s people (default: the first who may be emailed); from = the address of a user of your account (default: whoever created the token). Answers 201 with the email as it goes out (status: queued)
GET /api/v1/customers/:id/emails what was written to this customer, newest first: to_email, from_email, subject, body, text, status (queued | sent | failed), via (google | sumwerk), error, origin (person | automation | sequence), sent_at
GET/POST /api/v1/sequences, GET/PATCH/DELETE /api/v1/sequences/:id a sequence is sent and received whole: name, active, stop_when_paying, steps[]: subject, body, wait_days (days after the step before; ignored for the first)
POST /api/v1/customers/:id/enrollments sequence_id, to, from as for emails. The first step goes out within ten minutes
GET /api/v1/customers/:id/enrollments · DELETE /api/v1/enrollments/:id who is or was in which sequence (status: active | finished | stopped, stop_reason, next_step, next_run_at, emails_sent) · stop one

Emails go out from the sender’s own Google mailbox when they connected it, otherwise through sumwerk as “Name via sumwerk” with replies to them. A person marked do not email gets nothing (422). Every email carries an unsubscribe link and the one-click unsubscribe headers (where the text says {{ unsubscribe_url }}, otherwise a line at the end); unsubscribing marks the person do not email and stops their sequences. Automations have two more actions: send_email (template_id, from: "owner" or a user’s address, fallback_from) and enroll_in_sequence (sequence_id, from, fallback_from).

Linking Stripe customers changes MRR history; the recalculation runs a few seconds later, once for a burst of writes. Invalid input answers 422 {"error":"invalid","message":"…"} and changes nothing; a read-only token answers 403.

For AI assistants: MCP

The same data is available over the Model Context Protocol at https://www.sumwerk.com/mcp (Streamable HTTP, the same bearer tokens and scopes). Tools: mrr_by_month, mrr_movements, find_customers, get_customer, segments, funnel, revenue_at_risk, list_attributes, list_tasks, and with a read-and-write token add_note and add_task. How to connect Claude: Ask Claude about your customers.

Errors

An error is a status code and a JSON body with error, often with a message for a person. Nothing is changed by a call that answers with an error.

Status error When
400 invalid_parameter a date or number in the address cannot be read
401 unauthorized no token, or one that is unknown or revoked
403 forbidden a read-only token tried to write
404 not_found no such customer, task or automation in your account
422 invalid the body cannot be right; message names every problem
429 rate_limited more than 600 requests in a minute
503 fx_rates_missing an exchange rate for a reporting month is not in yet; try again later

Reference lists

Kinds: new, expansion, reactivation, contraction, churn. Causes: subscription_started, trial_conversion, resume, recovered, pause, delinquent, cancellation, subscription_ended, price_change, quantity, discount_ended, discount_applied. A source ending in :inferred means no invoice or event says when the change happened; it is dated to the last evidence.

A customer can be looked up by our id, by your own external id or by any of its Stripe customer ids. Movements and at-risk entries carry customer{id, external_id, stripe_id, name}.

CSV exports (signed in, from the Overview page)

Export URL One row per
Months /exports/months.csv?range=12\|24\|36\|all month: movements by kind + counts, net, FX effect, MRR, ARR, customers, ARPA, closed (yes / no), restatements, churn rates and revenue retention (fractions)
Movements /exports/movements.csv movement, all time: date, customer, kind, causes, native + reporting amount, MRR before/after, Stripe sources
Segments /exports/segments.csv?by=owner\|plan\|billing_interval\|tag\|country\|currency\|custom.<attribute> segment: paying customers, MRR, share, churn of the last 12 months, trial to paying
Funnel /exports/lifecycle.csv?range=12\|24\|36\|all month a trial started: leads, trials, converted, still in trial, not converted, rate, median days
Customers /exports/customers.csv (takes the list’s filters: status, q, owner, plan, billing_interval, tag, country, currency, attribute + value or min / max) customer: status, MRR, dates, owner, tags, ids, and one column per custom attribute (custom.<key>)
One month /months/2026-09.csv (optional ?kind=churn) movement of that month
At risk /at-risk.csv (optional ?severity=high\|medium\|low, ?reason=cancels\|unpaid\|past_due\|signal) subscription whose MRR still counts but is in doubt

Connect Stripe and check your own numbers

A restricted, read-only key is all it takes. The first import runs in a few minutes; every number you see opens into the customers and Stripe references behind it.

Start free