Skip to content

API documentation

Manage contacts, send events, run campaigns and flows from your own code, and get notified when things happen. Everything is JSON over HTTPS. The machine-readable description is at https://mailserver.ittypingtest.io/api/v1/openapi.json (OpenAPI 3.1 — import it into Postman, Insomnia or a code generator).

Getting started

  1. In the app, open Settings → API & webhooks and create a key. Copy it — it’s shown once.
  2. Call the API with it:
curl https://mailserver.ittypingtest.io/api/v1/contacts \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "first_name": "Jane", "status": "subscribed", "consent_source": "Signup page"}'

Authentication

Send the key in the Authorization: Bearer header. A key belongs to one workspace — that’s the only data it can reach — and stops working if it’s revoked, expires, or the team member who created it leaves. Each key has scopes:

ScopeAllows
contacts:readRead contacts, tags and properties
contacts:writeCreate and update contacts, tags and properties
contacts:deletePermanently delete contacts
lists:readRead lists
lists:writeCreate, change and delete lists and their members
segments:readRead segments and their members
segments:writeCreate segments
campaigns:readRead campaigns and reports
campaigns:writeCreate and edit draft campaigns
campaigns:sendSend, schedule and cancel campaigns
flows:readRead flows
flows:writeCreate flows, activate and pause them, add people
events:writeRecord events (purchases, custom events)
forms:readRead forms and submissions
webhooks:manageManage webhook endpoints
analytics:readRead analytics

Errors

Errors use HTTP status codes and always have the same body:

{ "message": "Some fields are invalid.", "code": "validation_failed", "errors": { "email": ["The email field must be a valid email address."] } }

code is one of: unauthenticated (401), insufficient_scope/forbidden (403), not_found (404), conflict (409, e.g. editing a campaign that’s sending), validation_failed (422), rate_limited (429), server_error (500).

Rate limits

120 requests per minute per workspace (all keys together). Every response carries X-RateLimit-Limit and X-RateLimit-Remaining; a 429 includes Retry-After in seconds. For volume, use the bulk endpoints (1,000 contacts or 100 events per call).

Pagination

Lists return newest first: {"data": [...], "next_cursor": "…"}. Pass ?cursor= with that value for the next page and ?limit= (1–200, default 50). next_cursor is null on the last page.

Idempotency

Add an Idempotency-Key header (any unique string up to 100 characters) to POST, PATCH, PUT or DELETE requests. If the request is retried with the same key and body within 24 hours, you get the first response again (with Idempotent-Replayed: true) and nothing happens twice. Events also accept unique_id, which de-duplicates forever.

Account

GET/api/v1/me

The workspace and key in use

Response 200

{
    "data": {
        "workspace": {
            "name": "Acme"
        },
        "key": {
            "scopes": [
                "*"
            ]
        },
        "rate_limit_per_minute": 120
    }
}

Contacts

GET/api/v1/contacts

List contacts (newest first) · scope contacts:read

Query parameters

  • limit — Items per page, 1–200 (default 50)
  • cursor — next_cursor from the previous page
  • email — Exact email
  • status — subscribed | non_subscribed | unsubscribed | pending | bounced | complained
  • list_id — Members of a list
  • segment_id — Members of a segment
  • tag — Tag name
  • updated_since — ISO 8601 date

Response 200

{
    "data": [
        {
            "id": 42,
            "email": "[email protected]",
            "first_name": "Jane",
            "status": "subscribed",
            "properties": {
                "plan": "pro"
            },
            "tags": [
                "vip"
            ],
            "lists": [
                {
                    "id": 3,
                    "name": "Newsletter"
                }
            ]
        }
    ],
    "next_cursor": "eyJpZCI6NDF9"
}

POST/api/v1/contacts

Create or update a contact by email · scope contacts:write

New contacts are not subscribed unless you send "status": "subscribed" (say where consent came from in "consent_source"). Bounced and complained addresses can’t be subscribed. Lists must exist; tags are created when needed.

Request

{
    "email": "[email protected]",
    "first_name": "Jane",
    "properties": {
        "plan": "pro"
    },
    "status": "subscribed",
    "consent_source": "Checkout opt-in",
    "lists": [
        3
    ],
    "tags": [
        "customer"
    ]
}

Response 201

{
    "data": {
        "id": 42,
        "email": "[email protected]",
        "first_name": "Jane",
        "status": "subscribed",
        "properties": {
            "plan": "pro"
        },
        "tags": [
            "vip"
        ],
        "lists": [
            {
                "id": 3,
                "name": "Newsletter"
            }
        ]
    },
    "created": true
}

POST/api/v1/contacts/bulk

Create or update up to 1,000 contacts · scope contacts:write

Request

{
    "contacts": [
        {
            "email": "[email protected]"
        },
        {
            "email": "[email protected]",
            "tags": [
                "import"
            ]
        }
    ]
}

Response 200

{
    "data": {
        "created": 2,
        "updated": 0,
        "errors": []
    }
}

GET/api/v1/contacts/{contact}

Get a contact · scope contacts:read

Response 200

{
    "data": {
        "id": 42,
        "email": "[email protected]",
        "first_name": "Jane",
        "status": "subscribed",
        "properties": {
            "plan": "pro"
        },
        "tags": [
            "vip"
        ],
        "lists": [
            {
                "id": 3,
                "name": "Newsletter"
            }
        ]
    }
}

PATCH/api/v1/contacts/{contact}

Update a contact (only the fields you send) · scope contacts:write

Request

{
    "last_name": "Doe",
    "properties": {
        "plan": "team"
    }
}

Response 200

{
    "data": {
        "id": 42,
        "email": "[email protected]",
        "first_name": "Jane",
        "status": "subscribed",
        "properties": {
            "plan": "pro"
        },
        "tags": [
            "vip"
        ],
        "lists": [
            {
                "id": 3,
                "name": "Newsletter"
            }
        ]
    }
}

DELETE/api/v1/contacts/{contact}

Permanently delete a contact (GDPR erasure) · scope contacts:delete

POST/api/v1/contacts/{contact}/subscribe

Subscribe to marketing email · scope contacts:write

Request

{
    "consent_source": "Account settings"
}

POST/api/v1/contacts/{contact}/unsubscribe

Unsubscribe from marketing email · scope contacts:write

GET/api/v1/contacts/{contact}/events

The contact’s timeline (events and changes) · scope contacts:read

Query parameters

  • limit — Items per page, 1–200 (default 50)
  • cursor — next_cursor from the previous page

Response 200

{
    "data": [
        {
            "type": "purchase",
            "name": "purchase",
            "properties": {
                "value": 49.9
            },
            "time": "2026-10-01T10:00:00+00:00"
        }
    ],
    "next_cursor": "eyJpZCI6NDF9"
}

POST/api/v1/contacts/{contact}/tags

Add tags · scope contacts:write

Request

{
    "tags": [
        "vip"
    ]
}

DELETE/api/v1/contacts/{contact}/tags

Remove tags · scope contacts:write

Request

{
    "tags": [
        "vip"
    ]
}

Tags & properties

GET/api/v1/tags

List tags · scope contacts:read

Response 200

{
    "data": [
        {
            "id": 1,
            "name": "vip",
            "contacts": 12
        }
    ]
}

POST/api/v1/tags

Create a tag · scope contacts:write

Request

{
    "name": "vip"
}

GET/api/v1/properties

List custom properties · scope contacts:read

Response 200

{
    "data": [
        {
            "key": "plan",
            "label": "Plan",
            "type": "text"
        }
    ]
}

POST/api/v1/properties

Create a custom property · scope contacts:write

Request

{
    "key": "plan",
    "label": "Plan",
    "type": "text"
}

Lists

GET/api/v1/lists

List lists · scope lists:read

Query parameters

  • limit — Items per page, 1–200 (default 50)
  • cursor — next_cursor from the previous page

POST/api/v1/lists

Create a list · scope lists:write

Request

{
    "name": "Newsletter",
    "description": "Weekly news"
}

GET/api/v1/lists/{list}

Get a list with member counts · scope lists:read

PATCH/api/v1/lists/{list}

Rename or describe a list · scope lists:write

Request

{
    "name": "Weekly newsletter"
}

DELETE/api/v1/lists/{list}

Delete a list (contacts stay) · scope lists:write

POST/api/v1/lists/{list}/contacts

Add existing contacts (ids and/or emails, up to 1,000) · scope lists:write

Request

{
    "emails": [
        "[email protected]"
    ],
    "contact_ids": [
        42
    ]
}

Response 200

{
    "data": {
        "added": 2,
        "matched": 2
    }
}

DELETE/api/v1/lists/{list}/contacts

Remove contacts from the list · scope lists:write

Request

{
    "contact_ids": [
        42
    ]
}

Segments

GET/api/v1/segments

List segments · scope segments:read

Query parameters

  • limit — Items per page, 1–200 (default 50)
  • cursor — next_cursor from the previous page

POST/api/v1/segments

Create a segment from a condition tree · scope segments:write

Request

{
    "name": "Engaged",
    "definition": {
        "type": "group",
        "match": "all",
        "children": [
            {
                "type": "condition",
                "field": "email_status",
                "operator": "is",
                "value": "subscribed"
            }
        ]
    }
}

GET/api/v1/segments/{segment}

Get a segment · scope segments:read

GET/api/v1/segments/{segment}/contacts

Contacts in the segment right now · scope segments:read

Query parameters

  • limit — Items per page, 1–200 (default 50)
  • cursor — next_cursor from the previous page

Campaigns

GET/api/v1/campaigns

List campaigns · scope campaigns:read

Query parameters

  • limit — Items per page, 1–200 (default 50)
  • cursor — next_cursor from the previous page
  • status — draft | scheduled | sending | sent | …

POST/api/v1/campaigns

Create a draft campaign · scope campaigns:write

Content from "template_id", "design" (block JSON as the builder stores it) or "html" (cleaned and placed above the standard footer with the unsubscribe link).

Request

{
    "name": "October news",
    "subject": "What’s new",
    "preheader": "Three things we shipped",
    "sender_id": 1,
    "audience": {
        "include_lists": [
            3
        ],
        "exclude_segments": [
            7
        ]
    },
    "html": "<h1>Hello {{ first_name | default: \"there\" }}</h1><p>…</p>"
}

GET/api/v1/campaigns/{campaign}

Get a campaign (with content) · scope campaigns:read

PATCH/api/v1/campaigns/{campaign}

Change a draft campaign · scope campaigns:write

Request

{
    "subject": "New subject"
}

POST/api/v1/campaigns/{campaign}/test

Send test emails (up to 5 addresses) · scope campaigns:write

Request

{
    "emails": [
        "[email protected]"
    ]
}

POST/api/v1/campaigns/{campaign}/send

Send now · scope campaigns:send

Runs the same checks as the app (sender, domain, content, audience); problems come back as 422.

POST/api/v1/campaigns/{campaign}/schedule

Schedule · scope campaigns:send

Request

{
    "send_at": "2026-11-01 09:00",
    "timezone": "Europe/London"
}

POST/api/v1/campaigns/{campaign}/cancel

Unschedule, or stop sending · scope campaigns:send

GET/api/v1/campaigns/{campaign}/report

Delivery and engagement numbers · scope campaigns:read

Response 200

{
    "data": {
        "sent": 1000,
        "delivered": 985,
        "unique_opens": 402,
        "open_rate": 40.8,
        "click_rate": 6.1
    }
}

Flows

GET/api/v1/flows

List flows · scope flows:read

Query parameters

  • limit — Items per page, 1–200 (default 50)
  • cursor — next_cursor from the previous page

POST/api/v1/flows

Create a draft flow (optionally from a starter) · scope flows:write

Request

{
    "name": "Welcome",
    "starter": "welcome"
}

GET/api/v1/flows/{flow}

Get a flow · scope flows:read

POST/api/v1/flows/{flow}/activate

Go live (or "manual" only) · scope flows:write

Request

{
    "mode": "live"
}

POST/api/v1/flows/{flow}/pause

Pause · scope flows:write

POST/api/v1/flows/{flow}/enroll

Add a contact to a live or manual flow · scope flows:write

Request

{
    "email": "[email protected]",
    "context": {
        "coupon": "WELCOME10"
    }
}

Events

POST/api/v1/events

Record an event · scope events:write

Events appear on the contact’s timeline, can start flows ("Does something" trigger) and be used in segments. Built-in names: purchase, product_viewed, started_checkout, cart_abandoned. Send "unique_id" to make retries safe. Unknown emails create a contact (not subscribed) unless "create_contact": false.

Request

{
    "event": "purchase",
    "email": "[email protected]",
    "unique_id": "order-1001",
    "properties": {
        "value": 49.9,
        "currency": "GBP",
        "items": [
            {
                "name": "Blue mug",
                "quantity": 2
            }
        ]
    },
    "time": "2026-10-01T10:00:00Z"
}

Response 202

{
    "data": {
        "recorded": true,
        "duplicate": false,
        "contact_id": 42,
        "contact_created": false
    }
}

POST/api/v1/events/bulk

Record up to 100 events · scope events:write

Request

{
    "events": [
        {
            "event": "product_viewed",
            "email": "[email protected]",
            "properties": {
                "name": "Blue mug"
            }
        }
    ]
}

Forms

GET/api/v1/forms

List forms · scope forms:read

Query parameters

  • limit — Items per page, 1–200 (default 50)
  • cursor — next_cursor from the previous page

GET/api/v1/forms/{form}/submissions

Submissions of a form · scope forms:read

Query parameters

  • limit — Items per page, 1–200 (default 50)
  • cursor — next_cursor from the previous page

Webhooks

GET/api/v1/webhooks

List webhook endpoints · scope webhooks:manage

Query parameters

  • limit — Items per page, 1–200 (default 50)
  • cursor — next_cursor from the previous page

POST/api/v1/webhooks

Add an endpoint (the signing secret is returned once) · scope webhooks:manage

Request

{
    "url": "https://example.com/hooks",
    "events": [
        "contact.created",
        "email.bounced"
    ]
}

GET/api/v1/webhooks/{webhook}

Get an endpoint · scope webhooks:manage

PATCH/api/v1/webhooks/{webhook}

Change an endpoint (or turn it on/off with "active") · scope webhooks:manage

Request

{
    "active": true
}

DELETE/api/v1/webhooks/{webhook}

Remove an endpoint · scope webhooks:manage

POST/api/v1/webhooks/{webhook}/test

Send a test event now · scope webhooks:manage

GET/api/v1/webhooks/{webhook}/deliveries

Delivery log (30 days) · scope webhooks:manage

Query parameters

  • limit — Items per page, 1–200 (default 50)
  • cursor — next_cursor from the previous page
  • status — pending | retrying | success | failed

Analytics

GET/api/v1/analytics/summary

Daily email totals · scope analytics:read

Query parameters

  • from — YYYY-MM-DD (default 30 days ago)
  • to — YYYY-MM-DD (default today)

Webhooks

Add an endpoint under Settings → API & webhooks → Webhooks (or POST /webhooks) and choose events. We POST JSON like this:

POST /your/endpoint
X-Sendora-Event: contact.created
X-Sendora-Delivery: 5b1f…          (the same on every retry: use it to ignore duplicates)
X-Sendora-Signature: t=1791450000,v1=3f7a…

{"id": "5b1f…", "type": "contact.created", "created_at": "2026-10-08T12:00:00+00:00", "data": {"contact": {…}}}
EventWhen
contact.createdContact created
contact.updatedContact updated
contact.unsubscribedContact unsubscribed
email.sentEmail sent
email.deliveredEmail delivered
email.openedEmail opened
email.clickedEmail clicked
email.bouncedEmail bounced
email.complainedEmail marked as spam
campaign.completedCampaign finished sending
flow.completedContact completed a flow

Answer with any 2xx status within 10 seconds. Otherwise we retry after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h. An endpoint that fails many times in a row is turned off and the workspace owner is notified. Opens and clicks by security scanners are not sent.

Verifying the signature

Compute HMAC-SHA256 of t + "." + raw body with your endpoint’s secret and compare it with v1. Reject timestamps older than 5 minutes.

// PHP
[$t, $v1] = sscanf($_SERVER['HTTP_X_SENDORA_SIGNATURE'], 't=%d,v1=%s');
$body = file_get_contents('php://input');
$valid = hash_equals(hash_hmac('sha256', $t.'.'.$body, $secret), $v1) && abs(time() - $t) < 300;

// Node.js
const [t, v1] = req.headers['x-sendora-signature'].split(',').map(p => p.split('=')[1]);
const expected = crypto.createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex');
const valid = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1)) && Math.abs(Date.now() / 1000 - t) < 300;

tracker.js

Add a website under Website tracking to get its code with your public site key, and paste it before </head>:

<script>
window.sendora=window.sendora||function(){(sendora.q=sendora.q||[]).push(arguments)};
sendora('init', 'pk_…');
</script>
<script async src="https://mailserver.ittypingtest.io/js/tracker.js"></script>
CallWhat it does
sendora('identify', '[email protected]', signature)Recognises a logged-in customer — only if they’re already a contact, and only with a signature made on your server: hash_hmac('sha256', strtolower($email), IDENTIFY_SECRET) (the secret is in Settings → Website tracking; never put it in browser code). Without a valid signature the call is ignored, and the answer is the same whether or not the email is a contact. Never creates or changes contacts.
sendora('track', 'Viewed product', {name: 'Blue mug'})Records an event (name becomes viewed_product). purchase can only come from your server.
sendora('page')Records a page view (automatic after init when page views are on).
sendora('consent', true)When the site waits for consent: nothing is stored or sent before this.
sendora('reset')Forgets the visitor (call on log out).
sendora('form', 'formId', '#where')Renders a form into an element.

Privacy by design: anonymous visitors are never stored. Events wait in the browser (at most 20) until the visitor is recognised — by filling in a form, clicking a link in one of your emails (to a domain you listed), or identify().

Forms

Build forms under Forms. The embed code is a placeholder plus the same script:

<div data-sendora-form="FORM_ID"></div>
<script async src="https://mailserver.ittypingtest.io/js/tracker.js"></script>

<!-- popup -->
<script async src="https://mailserver.ittypingtest.io/js/tracker.js" data-sendora-popup="FORM_ID"></script>

Forms are rendered in a shadow root, so your site’s CSS doesn’t affect them. After a successful submit the page receives a sendora:submitted event on window (event.detail.form is the form ID). You can also post a plain HTML form to https://mailserver.ittypingtest.io/f/FORM_ID with fields named f[email], f[first_name]…

Sendora API v1. Breaking changes will only ever ship as /api/v2.