Developer docs

Transactional email API

Send receipts, password resets, order updates and other one-to-one emails straight from your app. They go out through your own sending provider, with your verified senders, and every email shows up in your Klyve logs.

Introduction

The API is a small REST API that speaks JSON over HTTPS. Every request goes to this base URL:

Base URL
https://your-klyve-domain/api/v1

Campaigns are for sending one email to many people. The transactional API is for the opposite: one email to one person, triggered by something they did in your app. A typical flow:

  1. Your server calls POST /api/v1/emails with who it's for and what to say.
  2. We check it, save it and answer right away with the email's id and status queued.
  3. A few seconds later it's sent through your sending provider. You can see it under Transactional in the dashboard.

Bodies are JSON (send Content-Type: application/json), dates are ISO 8601 in UTC, and ids are opaque strings.

Authentication

Every request needs an API key. Create one under Settings → API keys (owners and admins can do this) and send it in the Authorization header:

Header
Authorization: Bearer klv_3fA9x2…

Keys start with klv_ followed by 40 letters and digits. A key belongs to one workspace and can do anything the API allows in that workspace. You can have up to 20 active keys per workspace, so give each app its own key and revoke it alone if it ever leaks.

Keep keys secret

Use keys only on your server. Never put them in website or mobile app code, and never commit them to a public repository. Store them in an environment variable such as KLYVE_API_KEY. We only keep a fingerprint of each key, so we can't show it to you again: if you lose one, create a new one and revoke the old one.

If your tool can't set the Authorization header, send the key as X-API-Key: klv_… instead. A missing, mistyped or revoked key gets a 401 with the code unauthorized. Until the workspace owner has verified their own email address, every request gets 403 forbidden.

Quick start

Five minutes from zero to an email in your inbox:

  1. Connect a sending provider and verify a sender address (if you already send campaigns, you've done this).
  2. Create an API key and save it as KLYVE_API_KEY on your server.
  3. Run this, with from changed to your verified sender and to changed to your own address:
curl -X POST https://your-klyve-domain/api/v1/emails \
  -H "Authorization: Bearer $KLYVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Your Shop <hello@yourshop.com>",
    "to": "anita@example.com",
    "subject": "Your order has shipped",
    "html": "<p>Hi {{ params.name }}, order #{{ params.order_id }} is on its way.</p>",
    "params": {
      "name": "Anita",
      "order_id": 1234
    }
  }'

You get back 202 Accepted and the email object. Open Transactional in the dashboard to watch it go from queued to sent.

Response · 202 Accepted
{
  "id": "cm4x8b2k10003qz8l2v6e9d1a",
  "object": "email",
  "status": "queued",
  "to": {
    "email": "anita@example.com",
    "name": "Anita"
  },
  "from": {
    "email": "hello@yourshop.com",
    "name": "Your Shop"
  },
  "replyTo": null,
  "subject": "Your order has shipped",
  "templateId": null,
  "tags": [
    "shipping"
  ],
  "metadata": {
    "orderId": "1234"
  },
  "error": null,
  "createdAt": "2026-11-13T09:30:00.000Z",
  "sentAt": null
}

Sending an email

POST/api/v1/emails
Sends one email to one recipient

The request body. Fields you don't know about are rejected, so typos are caught early.

fromstring | { email, name }required
Must be a verified sender of your workspace. Write it as "hello@shop.com", "Shop <hello@shop.com>" or { "email": "hello@shop.com", "name": "Shop" }. A name replaces the sender's display name for this email (up to 70 characters, and it can't look like an email address). Leave it out to use the sender's own name.
tostring | { email, name }required
One recipient, in the same formats as from. To email several people, make one request each.
subjectstringrequired without templateId
Up to 200 characters, no line breaks. Can use merge tags. With a template, leave it out to use the template's subject.
htmlstringhtml or text, or templateId
The HTML body, up to 512,000 characters. Can use merge tags. For safety it's cleaned like the editor's HTML block: scripts, forms, frames and <style>/<head> tags are removed, so put CSS inline (style="…"), which email apps prefer anyway.
textstring
The plain-text body, up to 200,000 characters. If you only send html, we make the text version from it; if you only send text, we make a simple HTML version from it.
templateIdstring
Send one of your saved templates instead of html/text (not both). See Using a template.
paramsobject
Values for {{ params.* }} merge tags, e.g. { "order_id": 1234 }. Up to 20,000 bytes as JSON.
replyTostring | { email, name }
Where replies go. Defaults to the sender's reply-to address.
tagsstring[]
Up to 10 labels to group and filter emails, e.g. ["receipt"]. Each up to 50 characters of letters, numbers and _ . : -.
metadataobject
Your own data (an order id, a user id…), up to 4,000 bytes as JSON. It's never put in the email; you get it back when you read the email.

Headers

AuthorizationBearer klv_…required
Your API key.
Content-Typeapplication/jsonrequired
The body is JSON.
Idempotency-Keystring
Up to 100 printable characters. Makes retries safe: see Idempotency.

A full example

Request
curl -X POST https://your-klyve-domain/api/v1/emails \
  -H "Authorization: Bearer $KLYVE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1234-shipped" \
  -d '{
    "from": {
      "email": "hello@yourshop.com",
      "name": "Your Shop"
    },
    "to": {
      "email": "anita@example.com",
      "name": "Anita"
    },
    "replyTo": "support@yourshop.com",
    "subject": "Your order #{{ params.order_id }} has shipped",
    "html": "<h1>Hi {{ params.name | default: \"there\" }}!</h1><p>Track it here: {{ params.tracking_url }}</p>",
    "text": "Hi {{ params.name | default: \"there\" }}! Track it here: {{ params.tracking_url }}",
    "params": {
      "name": "Anita",
      "order_id": 1234,
      "tracking_url": "https://track.example/abc"
    },
    "tags": [
      "shipping"
    ],
    "metadata": {
      "orderId": "1234"
    }
  }'

The answer is 202 Accepted with the email object and a Location header pointing at it. "Accepted" means we saved it and will send it within a few seconds; check its status to know how it went. Transactional emails have no open or click tracking.

How long we keep emails

The full content (HTML and text) is kept for 30 days so you can check what was sent, then cleared for privacy. The log entry (who, when, status) stays for 90 days.

Merge tags

The subject, HTML and text can contain merge tags in double curly braces. They're filled in for each email, so one template works for every customer.

TagBecomes
{{ params.order_id }}The value you sent in params.
{{ contact.FIRSTNAME }}A field of the recipient's contact in your workspace (EMAIL, FIRSTNAME, LASTNAME or any custom field such as CITY). If they aren't a contact, only contact.EMAIL is set.
{{ workspace.name }}Your workspace's name.
{{ current_year }}The current year, e.g. for a footer.

A tag with no value becomes empty text. Give it a fallback with default:

Default values
Hi {{ params.name | default: "there" }},

Values are inserted safely: in HTML they're escaped, so a customer named <b>Ann</b> shows up as exactly that text instead of breaking your email. Merge tags use the Liquid syntax, the same as in your templates.

Using a template

Design the email once in the Klyve editor (Templates), then send it with templateId and your params. Put merge tags like {{ params.reset_url }} in the template where the values go.

Request body
{
  "from": "Your Shop <hello@yourshop.com>",
  "to": "anita@example.com",
  "templateId": "cm4tq0x8n0001abcd2efgh345",
  "params": {
    "name": "Anita",
    "reset_url": "https://yourshop.com/reset?token=..."
  }
}
  • Find a template's id in the dashboard or with GET /api/v1/templates.
  • Leave out subject to use the template's subject, or send one to override it.
  • Don't send html or text with a template: that's a 400 invalid_request.
  • The email is built from the template as it is at the moment you call the API, so edits apply to the next email.

Idempotency

Networks fail. If your request times out, you can't know whether the email was accepted. Send an Idempotency-Key header and retrying becomes safe: the same key always means the same email, sent at most once.

Header
Idempotency-Key: order-1234-receipt
  • Use something unique to the event, like the order id plus the email type. Up to 100 printable characters.
  • Same key and same body again: you get the original email back (status 200) and nothing new is sent.
  • Same key with a different body: 409 idempotency_conflict, because that's almost always a bug.
  • Keys are remembered per workspace for as long as the email's log entry exists (90 days).

Email statuses

An email's status tells you where it is. When something went wrong, error says why in plain English.

StatusWhat it means
queuedWe accepted the email and it's waiting its turn to be sent. This usually takes a few seconds.
sendingIt's being handed to your sending provider right now. If the provider has a hiccup, we retry automatically.
sentYour sending provider accepted the email for delivery. It can still bounce later if the inbox doesn't exist.
failedWe couldn't send it, even after retrying. Nothing reached the recipient.
suppressedNot sent, on purpose: this address hard-bounced before, marked your email as spam, or asked to be erased. Sending to it again would hurt your reputation.
bouncedIt was sent, but the recipient's mail server rejected it (for example, the inbox doesn't exist).
complainedIt was delivered, and the recipient marked it as spam. We won't send them email again.

Who we won't email

Transactional emails are things people expect, like receipts and password resets, so the rules are gentler than for campaigns:

  • Unsubscribed contacts still get transactional email. Unsubscribing stops marketing, not their receipts.
  • Addresses that hard-bounced, people who marked your email as spam, people whose data was erased (privacy request) and addresses your team added to the suppression list by hand are not emailed. The email is saved with status suppressed and error says why. The API still answers 202, so your app doesn't need special handling. (The content of an email to an erased person isn't kept.)

Reading emails

GET/api/v1/emails/{id}
One email
GET/api/v1/emails
Your emails, newest first

Both return the email object (the list wraps them in data):

idstring
The email's id.
object"email"
Always "email".
statusstring
One of the statuses, in lower case.
to / from{ email, name }
The recipient and sender (name may be missing).
replyTostring | null
The reply-to address, if any.
subjectstring
The final subject, with merge tags filled in.
templateIdstring | null
The template used, if any.
tagsstring[]
Your tags.
metadataobject | null
Your metadata, exactly as you sent it.
errorstring | null
Why it failed, was suppressed or bounced.
createdAtstring
When we accepted it (ISO 8601, UTC).
sentAtstring | null
When your provider accepted it.

Listing and pagination

The list takes these optional query parameters:

limitnumber
How many to return, 1 to 100 (default 20).
cursorstring
The nextCursor from the previous page.
statusstring
Only this status, e.g. failed.
tostring
Only emails to this address.
tagstring
Only emails with this tag.
Request
curl "https://your-klyve-domain/api/v1/emails?limit=2&status=sent" \
  -H "Authorization: Bearer $KLYVE_API_KEY"
Response · 200 OK
{
  "object": "list",
  "data": [
    {
      "id": "cm4x8b2k10003qz8l2v6e9d1a",
      "object": "email",
      "status": "sent",
      "to": {
        "email": "anita@example.com",
        "name": "Anita"
      },
      "from": {
        "email": "hello@yourshop.com",
        "name": "Your Shop"
      },
      "replyTo": null,
      "subject": "Your order has shipped",
      "templateId": null,
      "tags": [
        "shipping"
      ],
      "metadata": {
        "orderId": "1234"
      },
      "error": null,
      "createdAt": "2026-11-13T09:30:00.000Z",
      "sentAt": "2026-11-13T09:30:04.000Z"
    },
    "…"
  ],
  "nextCursor": "cm4x7z…"
}

When nextCursor is null you've reached the end. Otherwise pass it as cursor (with the same filters) to get the next page. Unknown query parameters are a 400, so typos don't silently return everything. Unknown ids, and ids from another workspace, get 404 not_found.

Contacts

Keep your Klyve contacts in sync with your app, for example when someone signs up. Their fields are then available as {{ contact.* }} merge tags in transactional emails and campaigns.

POST/api/v1/contacts
Create or update a contact
emailstringrequired
Finds the contact by email. If there's none, it's created.
firstName / lastNamestring | null
Up to 100 characters each.
attributesobject
Custom fields by key, e.g. { "CITY": "Pune", "AGE": 31 }. Each field must already exist (Contacts → Attributes). null clears a value; fields you leave out stay as they are.
listIdsstring[]
Lists to add the contact to (up to 50). It's never removed from other lists.
status"subscribed" | "unsubscribed"
Leave it out and new contacts are subscribed. "unsubscribed" unsubscribes an existing contact (or creates one that doesn't get campaigns).
updateExistingboolean
Default true. Set false to only create: an existing contact is then left untouched.
Request body
{
  "email": "anita@example.com",
  "firstName": "Anita",
  "attributes": {
    "CITY": "Pune"
  },
  "listIds": [
    "cm4list0001abcd"
  ]
}

The answer is 201 Created for a new contact or 200 OK for an update, with the contact. When we kept something as it was, a note explains why:

Response · 200 OK
{
  "id": "cm4c0ntact01abcd",
  "object": "contact",
  "email": "anita@example.com",
  "firstName": "Anita",
  "lastName": null,
  "status": "unsubscribed",
  "attributes": {
    "CITY": "Pune"
  },
  "listIds": [
    "cm4list0001abcd"
  ],
  "createdAt": "2026-10-02T08:12:00.000Z",
  "updatedAt": "2026-11-13T09:30:00.000Z",
  "note": "This contact unsubscribed, so the API can't subscribe them again. They can re-subscribe themselves through a sign-up form or the preference centre."
}

Consent stays in the contact's hands

The API never re-subscribes someone who unsubscribed, bounced or complained, and it won't add an address that's on your suppression list. If your workspace uses double opt-in, new contacts start as pending and get the confirmation email first, just like with a sign-up form. Every subscription is recorded in the contact's consent history as coming from the API. Contacts can't be deleted with an API key; do that in the dashboard.
GET/api/v1/contacts/{id or email}
Read one contact

Use the contact's id or its email address (URL-encoded, e.g. anita%40example.com). Returns the contact as above (without note), or 404 not_found.

Senders & templates

GET/api/v1/senders
Your verified senders

The addresses you can use as from.

Response · 200 OK
{
  "object": "list",
  "data": [
    {
      "object": "sender",
      "id": "cm4s3nder01abcd",
      "email": "hello@yourshop.com",
      "name": "Your Shop"
    }
  ]
}
GET/api/v1/templates
Your templates

Most recently edited first, to pick a templateId. Takes limit (1 to 100, default 20) and cursor, and pages with nextCursor like the email list.

Response · 200 OK
{
  "object": "list",
  "data": [
    {
      "object": "template",
      "id": "cm4tq0x8n0001abcd2efgh345",
      "name": "Password reset",
      "subject": "Reset your password",
      "updatedAt": "2026-11-10T14:03:00.000Z"
    }
  ],
  "nextCursor": null
}

/me

GET/api/v1/me
Check your key

Returns the workspace and the key you're using. A quick way to test that a key works before you send anything:

Request
curl https://your-klyve-domain/api/v1/me \
  -H "Authorization: Bearer $KLYVE_API_KEY"
Response · 200 OK
{
  "workspace": {
    "id": "cm4w0rkspace1abc",
    "name": "Your Shop"
  },
  "apiKey": {
    "id": "cm4k3y0001abcd",
    "name": "Shop backend",
    "prefix": "klv_3fA9x2"
  }
}

Errors

Errors use normal HTTP status codes and always have the same JSON shape, with a stable code your code can check and a message written for people.

Response · 400 Bad Request
{
  "error": {
    "code": "invalid_request",
    "message": "Some fields are missing or invalid. See details.",
    "details": [
      {
        "path": "subject",
        "message": "A subject is required (unless you use a template)."
      }
    ]
  }
}
HTTPcodeWhen
400invalid_requestThe body isn't JSON (or Content-Type isn't application/json), is too large, or a field is missing or wrong. details lists each problem.
401unauthorizedThe API key is missing, mistyped or revoked.
403forbiddenThe key is fine, but this action isn't allowed (for example, the account's email isn't verified yet).
404not_foundNo email, template or contact with that id in your workspace.
405method_not_allowedThe endpoint exists but not with this method (e.g. DELETE a contact). The Allow header lists the methods it accepts.
422sender_not_verified"from" isn't a verified sender of your workspace. Verify it under Settings → Senders.
422no_providerYour workspace has no working sending provider. Connect one under Settings → Sending providers.
409idempotency_conflictYou reused an Idempotency-Key with a different body.
429rate_limitedToo many requests. Wait for the number of seconds in the Retry-After header.
500internal_errorSomething broke on our side. It's safe to retry with the same Idempotency-Key.

4xx errors mean the request needs fixing (retrying the same request won't help, except 429). 5xx errors are on us: retry after a short wait, with the same Idempotency-Key. An address that isn't an endpoint gets 404 not_found.

Every answer has an X-Request-Id header (like req_5f2c…). If you need help with a failed request, send us that id: it lets us find it in our logs.

Rate limits

Each API key can make 10 requests per second, with short bursts of up to 20. Over that, you get 429 rate_limited with a Retry-After header saying how many seconds to wait. Every answer to a request with a valid key tells you where you stand:

HeaderMeaning
RateLimit-LimitThe most requests you can make at once (20).
RateLimit-RemainingHow many you can make right now.
RateLimit-ResetSeconds until you're back to the full amount.
Retry-AfterSeconds to wait before retrying (only on 429).

To stop key guessing, an IP address that sends 20 invalid keys within a minute gets 429 (with Retry-After) for the rest of that minute.

There's no daily limit from Klyve. Your sending provider may have its own; if it pushes back, we slow down and retry for you.

Good practices

  • Keep keys on your server. Call the API from your backend only. A key in browser or app code can be copied by anyone.
  • One key per app. Name keys after where they're used ("Shop backend"), so you can revoke one without breaking the others.
  • Retry with an Idempotency-Key. On a timeout or a 5xx, retry with the same key and the customer still gets exactly one email.
  • Verify your sending domain. Set up SPF, DKIM and DMARC under Settings → Domains so your emails land in the inbox, not spam.
  • Send text as well as HTML. Some people read plain text, and spam filters like emails that have both.
  • Use tags. Tag each kind of email ("receipt", "password-reset") to filter the logs and spot problems fast.

Ready to send?

Create a key and your first email is one request away. Base URL: https://your-klyve-domain/api/v1

Create an API key