ScheduleKaro Email

Email API

Send receipts, sign-in codes and campaigns from your own domain. One HTTP call, delivery and bounce tracking included, and a suppression list that is enforced for you rather than left to your code.

Get an API keyOpenAPI documentPricing

Before your first call

  1. 1. Verify a domain.Add your domain in the dashboard and publish the DKIM, SPF and DMARC records it gives you. You may only send from a domain you own - that is what keeps everyone else's mail out of the spam folder as well as yours.
  2. 2. Create an API key. It is shown once. Give it only the scopes it needs, and keep it on your server - a key in browser JavaScript is a key anyone can read.
  3. 3. Send. Post to /api/email/v1/send with the key in an Authorization: Bearer header.
curl https://schedulekaro.com/api/email/v1/send \
  -H "Authorization: Bearer sk_live_xxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "hello@yourdomain.com",
    "to": "customer@example.com",
    "subject": "Your order is on its way",
    "html": "<p>Thanks for your order.</p>"
  }'

How responses look

Every response is a JSON object with an ok field. On failure it carries a stable error.code as well as a sentence - write your code against the code, not the sentence, because the sentence is written for a human and may improve.

CodeHTTPWhat it means
BAD_REQUEST400A field is missing or the wrong shape. The detail says which.
UNAUTHORIZED401The API key is missing, revoked or expired.
FORBIDDEN403The key is valid but does not have the scope this call needs.
NOT_FOUND404No message, template or domain with that identifier.
PAYLOAD_TOO_LARGE413The request body is over 256 KB.
RATE_LIMITED429Too many calls. Retry after a short pause.
QUOTA_EXCEEDED402The monthly allowance and the credit balance are both spent.
SUPPRESSED403The recipient is on a suppression list and was not emailed.
DOMAIN_NOT_VERIFIED403The From domain is not verified for this account.
DOMAIN_PAUSED403Sending from that domain is stopped. The dashboard says why.
DAILY_LIMIT429The domain has sent as much as its warm-up allows today.
MISSING_VARIABLES400A template needed values that were not in `data`.
SERVER_ERROR500Our fault. Safe to retry.
SERVICE_UNAVAILABLE503The email service is not open yet. Retry once it launches.

Scopes

Rate limits

Calls are limited per key and per IP address. A RATE_LIMITED reply is safe to retry after a pause. Separately, each verified domain has a daily sending limit that rises as its reputation holds - a new domain starts small on purpose.

Sending

Every send is checked against the suppression list, your quota and the verified From domain before it reaches Amazon SES.

POST/api/email/v1/sendscope: send

Send one email

The From address must be on a domain you have verified. Marketing mail obeys unsubscribes; transactional mail is never blocked by one.

Body

from
string · required
Address on a verified domain. "Name <hello@yourdomain.com>" is accepted.
to
string · required
Recipient address.
subject
string · required
Subject line.
html
string
HTML body. At least one of html or text is required.
text
string
Plain text body.
replyTo
string
Where replies should go, if not the From address.
kind
string
"transactional" (default) or "marketing".

Request

curl -X POST https://schedulekaro.com/api/email/v1/send \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "from": "hello@yourdomain.com",
  "to": "customer@example.com",
  "subject": "Your order is on its way",
  "html": "<p>Thanks for your order.</p>"
}'

Response · 202

{
  "ok": true,
  "data": {
    "id": "6f2f5f0c-2d1e-4f6f-9f1a-6f9a2d3b4c5d",
    "providerMessageId": "0100019...-000000"
  }
}
POST/api/email/v1/send-templatescope: send

Send a stored template

Renders the published version of a template you saved in the dashboard. A template that needs a value you did not supply is refused rather than sent with a blank in it.

Body

template
string · required
The template's name, e.g. order-receipt.
from
string · required
Address on a verified domain.
to
string · required
Recipient address.
data
object
Values for the template's variables.
replyTo
string
Where replies should go.
kind
string
"transactional" (default) or "marketing".

Request

curl -X POST https://schedulekaro.com/api/email/v1/send-template \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "template": "order-receipt",
  "from": "billing@yourdomain.com",
  "to": "customer@example.com",
  "data": {
    "first_name": "Asha",
    "total": "₹1,240"
  }
}'

Response · 202

{
  "ok": true,
  "data": {
    "id": "6f2f5f0c-2d1e-4f6f-9f1a-6f9a2d3b4c5d",
    "providerMessageId": "0100019...-000000",
    "template": "order-receipt",
    "templateVersion": 3
  }
}
POST/api/email/v1/send-batchscope: send

Send up to fifty at once

Each entry is sent on its own merits and reported on its own line, so one suppressed address does not fail the rest. Always returns 200 - read the entries, not the status code.

Body

from
string
Default From for every entry that does not set its own.
kind
string
Default kind for every entry.
messages
array · required
Up to 50 entries. Each takes the same fields as /send, or a template and data.

Request

curl -X POST https://schedulekaro.com/api/email/v1/send-batch \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "from": "hello@yourdomain.com",
  "messages": [
    {
      "to": "a@example.com",
      "subject": "Hi",
      "html": "<p>Hi</p>"
    },
    {
      "to": "b@example.com",
      "template": "welcome",
      "data": {
        "first_name": "Ravi"
      }
    }
  ]
}'

Response · 200

{
  "ok": true,
  "data": {
    "accepted": 1,
    "rejected": 1,
    "results": [
      {
        "index": 0,
        "to": "a@example.com",
        "ok": true,
        "id": "6f2f5f0c-..."
      },
      {
        "index": 1,
        "to": "b@example.com",
        "ok": false,
        "error": {
          "code": "SUPPRESSED",
          "detail": "This address unsubscribed."
        }
      }
    ]
  }
}

Email verification

A one-time code to prove somebody owns the address they typed. One live code per address: asking again replaces the last one rather than leaving several valid.

POST/api/email/v1/verification/startscope: send

Send a verification code

Sends a six-digit code and returns when it expires. The code itself is never returned - only the person holding the inbox learns it.

Body

from
string · required
Address on a verified domain.
email
string · required
Address to verify.
brand
string
Your name, as it should read in the mail.
subject
string
Override the default subject.

Request

curl -X POST https://schedulekaro.com/api/email/v1/verification/start \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "from": "hello@yourdomain.com",
  "email": "customer@example.com",
  "brand": "Kiran Tea Co."
}'

Response · 200

{
  "ok": true,
  "data": {
    "email": "customer@example.com",
    "expiresAt": "2026-09-01T09:25:00.000Z",
    "messageId": "6f2f5f0c-..."
  }
}
POST/api/email/v1/verification/checkscope: send

Check a code

A wrong code counts an attempt; the challenge is spent after five. A correct one can only be used once.

Body

email
string · required
The address the code was sent to.
code
string · required
What the person typed.

Request

curl -X POST https://schedulekaro.com/api/email/v1/verification/check \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "customer@example.com",
  "code": "418302"
}'

Response · 200

{
  "ok": true,
  "data": {
    "email": "customer@example.com",
    "verified": true
  }
}

Messages

What happened to everything you sent.

GET/api/email/v1/messagesscope: read

List messages

Newest first, paged on a creation-time cursor. Pass the response's nextBefore as `before` to get the next page; it is null when there is nothing older.

Query

limit
number
1-100. Defaults to 50.
status
string
queued, sent, delivered, bounced, complained, rejected or failed.
to
string
Only messages to this address.
before
string
ISO 8601 timestamp - return messages older than this.

Request

curl -X GET https://schedulekaro.com/api/email/v1/messages \
  -H "Authorization: Bearer YOUR_API_KEY"

Response · 200

{
  "ok": true,
  "data": {
    "messages": [
      {
        "id": "6f2f5f0c-...",
        "to": "customer@example.com",
        "subject": "Your order is on its way",
        "status": "delivered",
        "createdAt": "2026-09-01T09:15:00.000Z"
      }
    ],
    "nextBefore": null
  }
}
GET/api/email/v1/messages/{id}scope: read

One message and its events

The whole story of one send - delivered, opened, clicked, bounced - in a single call, so you can answer "did it arrive?" without building a webhook first.

Request

curl -X GET https://schedulekaro.com/api/email/v1/messages/{id} \
  -H "Authorization: Bearer YOUR_API_KEY"

Response · 200

{
  "ok": true,
  "data": {
    "id": "6f2f5f0c-...",
    "status": "delivered",
    "openedAt": "2026-09-01T09:31:00.000Z",
    "events": [
      {
        "type": "send",
        "occurredAt": "2026-09-01T09:15:00.000Z"
      },
      {
        "type": "delivery",
        "occurredAt": "2026-09-01T09:15:04.000Z"
      },
      {
        "type": "open",
        "occurredAt": "2026-09-01T09:31:00.000Z"
      }
    ]
  }
}

Analytics

Delivery and engagement by day, read from a rollup that is recomputed every fifteen minutes. Cheap to poll, and the same numbers the dashboard shows.

GET/api/email/v1/statsscope: read

Delivery and engagement statistics

Totals and a per-day series for a date range, optionally for one sending domain. Days are IST calendar days, the day the mail was sent - a bounce that arrives the next morning is counted against the day of the send. Rates are percentages: delivery and bounce against what Amazon SES accepted, the rest against what was delivered.

Query

from
string
First day, YYYY-MM-DD. Defaults to 29 days ago.
to
string
Last day, YYYY-MM-DD. Defaults to today. At most 366 days in a range.
domain
string
Only mail sent from this verified domain.

Request

curl -X GET https://schedulekaro.com/api/email/v1/stats \
  -H "Authorization: Bearer YOUR_API_KEY"

Response · 200

{
  "ok": true,
  "data": {
    "range": {
      "from": "2026-08-05",
      "to": "2026-09-03",
      "days": 30
    },
    "domain": null,
    "totals": {
      "sent": 12480,
      "accepted": 12475,
      "delivered": 12310,
      "opened": 5104,
      "clicked": 918,
      "bounced": 140,
      "complained": 21,
      "unsubscribed": 46,
      "failed": 5,
      "rejected": 0,
      "rates": {
        "delivery": 98.68,
        "open": 41.46,
        "click": 7.46,
        "bounce": 1.12,
        "complaint": 0.17,
        "unsubscribe": 0.37
      }
    },
    "daily": [
      {
        "day": "2026-09-03",
        "sent": 420,
        "accepted": 420,
        "delivered": 415,
        "opened": 190,
        "clicked": 33,
        "bounced": 5,
        "complained": 0,
        "unsubscribed": 2,
        "failed": 0,
        "rejected": 0,
        "rates": {
          "delivery": 98.81,
          "open": 45.78,
          "click": 7.95,
          "bounce": 1.19,
          "complaint": 0,
          "unsubscribe": 0.48
        }
      }
    ]
  }
}

Suppressions

Addresses that must not be emailed. Scope matters: "marketing" stops campaigns but still lets a receipt or a sign-in code through, which is what an unsubscribe usually means.

GET/api/email/v1/suppressionsscope: read

List suppressed addresses

Newest first.

Query

limit
number
1-500. Defaults to 100.

Request

curl -X GET https://schedulekaro.com/api/email/v1/suppressions \
  -H "Authorization: Bearer YOUR_API_KEY"

Response · 200

{
  "ok": true,
  "data": {
    "suppressions": [
      {
        "email": "gone@example.com",
        "scope": "all",
        "reason": "bounce",
        "createdAt": "2026-08-30T11:02:00.000Z"
      }
    ]
  }
}
POST/api/email/v1/suppressionsscope: suppressions:write

Suppress an address

Suppressing an address again widens or refreshes it rather than failing.

Body

email
string · required
Address to stop emailing.
scope
string
"marketing" (default) or "all".
detail
string
Your own note about why.

Request

curl -X POST https://schedulekaro.com/api/email/v1/suppressions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "gone@example.com",
  "scope": "all"
}'

Response · 201

{
  "ok": true,
  "data": {
    "email": "gone@example.com",
    "scope": "all"
  }
}
DELETE/api/email/v1/suppressionsscope: suppressions:write

Remove a suppression

Only removes an entry you added yourself. A bounce or a complaint is not yours to overrule, and an unsubscribe was the recipient's decision.

Body

email
string · required
Address to release. May also be passed as ?email=.

Request

curl -X DELETE https://schedulekaro.com/api/email/v1/suppressions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "asked-again@example.com"
}'

Response · 200

{
  "ok": true,
  "data": {
    "email": "asked-again@example.com",
    "removed": true
  }
}

Address validation

Check an address before you accept it. Catching a typo on a sign-up form saves an account that can never be confirmed, a support ticket, and a bounce against your sending domain.

POST/api/email/v1/validatescope: read

Check one address or up to fifty

Four checks: the shape of the address, whether the domain is a throwaway mailbox service, whether it is a shared desk address like info@, and whether the domain can receive mail at all. "invalid" means it cannot be delivered to and sending would bounce; "risky" means it will accept mail and probably is not worth sending; "pending" means DNS could not be reached and the answer is unknown - retry rather than reject. It does not connect to the mailbox, so "valid" means nothing is wrong with the address, not that somebody is reading it. Sends nothing and costs no quota.

Body

email
string
One address to check. Use this or `emails`.
emails
array
Up to fifty addresses. The response carries a `results` array in the same order.

Request

curl -X POST https://schedulekaro.com/api/email/v1/validate \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "priya@acme.in"
}'

Response · 200

{
  "ok": true,
  "data": {
    "email": "priya@acme.in",
    "result": "valid",
    "reason": "ok",
    "detail": "Nothing wrong with this address.",
    "deliverable": true
  }
}

Domains

The domains you may send from, and the state of their DNS.

GET/api/email/v1/domainsscope: read

List sending domains

Includes the DKIM, SPF, DMARC and MAIL FROM state, the health score, and the DNS records we last read at your nameservers.

Request

curl -X GET https://schedulekaro.com/api/email/v1/domains \
  -H "Authorization: Bearer YOUR_API_KEY"

Response · 200

{
  "ok": true,
  "data": {
    "domains": [
      {
        "domain": "yourdomain.com",
        "verificationStatus": "verified",
        "dkimStatus": "verified",
        "spfStatus": "verified",
        "dmarcStatus": "pending",
        "healthScore": 85,
        "dailyLimit": 1000,
        "paused": false
      }
    ]
  }
}

Workflows

Automated sequences you design in the dashboard - a wait, an email, a condition - started by your own application.

GET/api/email/v1/workflowsscope: read

List workflows

Every workflow on the account, with what starts it and whether it is live. Use it to find the id the enroll call needs.

Query

limit
number
1-200. Defaults to 50.

Request

curl -X GET https://schedulekaro.com/api/email/v1/workflows \
  -H "Authorization: Bearer YOUR_API_KEY"

Response · 200

{
  "ok": true,
  "data": {
    "workflows": [
      {
        "id": "8f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
        "name": "Welcome series",
        "status": "active",
        "trigger": "api",
        "enrolled": 412,
        "completed": 297
      }
    ]
  }
}
POST/api/email/v1/workflows/{id}/enrollscope: workflows:write

Put a contact into a workflow

The person must already be one of your contacts and must have confirmed their subscription - this is not a way to add somebody to a list. Enrolling the same person twice does nothing unless the workflow allows re-entry, in which case they start again from the first step.

Body

email
string · required
The contact to enroll.

Request

curl -X POST https://schedulekaro.com/api/email/v1/workflows/{id}/enroll \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "asha@example.com"
}'

Response · 202

{
  "ok": true,
  "data": {
    "workflowId": "8f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
    "email": "asha@example.com",
    "outcome": "enrolled"
  }
}

Webhooks

Rather than polling, point a URL at your server in the dashboard and we post every delivery, bounce, complaint, open, click and unsubscribe to it as JSON. Each request is signed with your endpoint secret, and a delivery your server missed is retried rather than dropped.

Open the email dashboard