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
- In the app, open Settings → API & webhooks and create a key. Copy it — it’s shown once.
- 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:
| Scope | Allows |
|---|---|
contacts:read | Read contacts, tags and properties |
contacts:write | Create and update contacts, tags and properties |
contacts:delete | Permanently delete contacts |
lists:read | Read lists |
lists:write | Create, change and delete lists and their members |
segments:read | Read segments and their members |
segments:write | Create segments |
campaigns:read | Read campaigns and reports |
campaigns:write | Create and edit draft campaigns |
campaigns:send | Send, schedule and cancel campaigns |
flows:read | Read flows |
flows:write | Create flows, activate and pause them, add people |
events:write | Record events (purchases, custom events) |
forms:read | Read forms and submissions |
webhooks:manage | Manage webhook endpoints |
analytics:read | Read 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 pageemail— Exact emailstatus— subscribed | non_subscribed | unsubscribed | pending | bounced | complainedlist_id— Members of a listsegment_id— Members of a segmenttag— Tag nameupdated_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 pagestatus— 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 pagestatus— 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": {…}}}
| Event | When |
|---|---|
contact.created | Contact created |
contact.updated | Contact updated |
contact.unsubscribed | Contact unsubscribed |
email.sent | Email sent |
email.delivered | Email delivered |
email.opened | Email opened |
email.clicked | Email clicked |
email.bounced | Email bounced |
email.complained | Email marked as spam |
campaign.completed | Campaign finished sending |
flow.completed | Contact 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>
| Call | What 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.