Documentation
Everything you need to send email with Sankofa: one HTTP request to send, a dashboard to manage senders and keys, and webhooks to follow delivery.
Base URL: https://api.trysankofa.com
Quickstart
You can send your first email in a couple of minutes, before setting up a domain.
- Create an account and confirm your email address.
- Open API Keys in the dashboard and create a key. It starts with
esk_and is shown once, so save it somewhere safe. - Send yourself an email from the shared test sender. Replace
you@yourcompany.comwith the email address you signed up with; the test sender can only email that address.
$ curl -X POST https://api.trysankofa.com/v1/emails \ -H "Authorization: Bearer esk_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "from": "hello@test.trysankofa.com", "to": "you@yourcompany.com", "subject": "Hello from Sankofa", "text": "My first email." }'The response contains a messageId. Check your inbox (and the spam folder the first time). When you're ready to email other people, verify a sender of your own.
Authentication
Every request carries an API key in the Authorization header:
Authorization: Bearer esk_your_api_key- Create and revoke keys on the API Keys page. A key is shown once, when you create it.
- Call the API from your server, never from a web page or a mobile app. Anyone who can see the key can send email as you.
- Use a separate key for each system, so you can revoke one without breaking the others.
- A key can do everything in this documentation except create, change or revoke keys. That needs the dashboard.
Who you can send from
The from address of every email must be one your account is allowed to use. There are three options, from quickest to best.
1. The shared test sender
hello@test.trysankofa.com works on every account with no setup. It can only send to your own account email address, one recipient at a time. Use it to try the API.
2. A single address
Confirm that you control one mailbox, such as hello@yourcompany.com, and you can send from exactly that address to anyone. Add it on the Domains page or with the API, then click the link we email to that address.
$ curl -X POST https://api.trysankofa.com/v1/senders \ -H "Authorization: Bearer esk_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "email": "hello@yourcompany.com" }'- Free mailbox addresses (Gmail, Yahoo, Outlook and similar) can't be used as senders. Mail claiming to come from those providers but sent through another service gets rejected or junked.
- If the address's domain publishes a strict anti-spoofing policy (DMARC set to quarantine or reject), a single address isn't enough and the API answers
422 DomainPolicyError. Verify the whole domain instead.
3. A whole domain
Verifying your domain lets you send from any address on it, and signs your email with your own domain so it passes authentication checks. This is the right setup for production. Add the domain on the Domains page or with the API:
$ curl -X POST https://api.trysankofa.com/v1/domains \ -H "Authorization: Bearer esk_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "domain": "yourcompany.com" }'The response lists the DNS records to create with your domain provider:
{
"domain": "yourcompany.com",
"dkimCnameRecords": [
{
"type": "CNAME",
"name": "abc123._domainkey.yourcompany.com",
"value": "abc123.dkim.amazonses.com"
}
// ...two more
],
"spfTxtRecord": {
"type": "TXT",
"name": "yourcompany.com",
"value": "v=spf1 include:amazonses.com ~all"
},
"dnsCheck": [
{ "record": { "type": "CNAME", "name": "...", "value": "..." }, "found": false, "actualValues": [] }
]
}- Create all three CNAME records. These are what verify the domain.
- If your domain already has an SPF record (a TXT record starting with
v=spf1), addinclude:amazonses.comto that record instead of creating a second one. A domain must have only one SPF record. dnsCheckshows whether each record is visible yet. DNS changes usually appear within minutes but can take up to a day.
The domain is ready when its verificationStatus is SUCCESS:
$ curl https://api.trysankofa.com/v1/domains/yourcompany.com \ -H "Authorization: Bearer esk_your_api_key"{
"domain": "yourcompany.com",
"verificationStatus": "SUCCESS",
"dkimStatus": "SUCCESS"
}Send an email
POST/v1/emails
$ curl -X POST https://api.trysankofa.com/v1/emails \ -H "Authorization: Bearer esk_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme <hello@yourcompany.com>", "to": ["customer@example.com"], "subject": "Your order has shipped", "html": "<p>Your order is on its way.</p>", "text": "Your order is on its way." }'| Field | Type | Description | |
|---|---|---|---|
| from | string | required | The sender, as "hello@yourcompany.com" or "Acme <hello@yourcompany.com>". Must be a sender your account can use. |
| to | string or string[] | required | One address, or a list of up to 50. Everyone on the list is in the To line and can see the others, so send separate emails when recipients shouldn't see each other. |
| subject | string | required | The subject line. Not needed when sending a template. |
| html | string | optional | The HTML body. |
| text | string | optional | The plain-text body. Provide html, text, or both; sending both gives the best deliverability. |
| templateName | string | optional | Send a saved template instead of subject, html and text. See Templates and bulk sending. |
| templateData | object | optional | Values for the template's variables. |
| attachments | object[] | optional | Files to attach. See Attachments below. |
A successful request returns 202 Accepted with the email's id. Keep it if you want to look the email up later or match it to webhook events.
{
"messageId": "010201a12003b0ff-bb6f1fc0-7a46-4d2f-9549-e60f575cf3a9-000000"
}- Each recipient uses one credit and counts once toward the API key's daily limit, if it has one.
- If a request fails, nothing is charged. Requests aren't de-duplicated, so retrying one that succeeded sends the email again.
- The request body can be up to 10 MB.
From Node.js
Any language that can make an HTTPS request works. For example, with the built-in fetch in Node.js 18 or later:
const response = await fetch("https://api.trysankofa.com/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SANKOFA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "Acme <hello@yourcompany.com>",
to: "customer@example.com",
subject: "Your order has shipped",
html: "<p>Your order is on its way.</p>",
}),
});
const body = await response.json();
if (!response.ok) throw new Error(`${body.error}: ${body.message}`);
console.log(body.messageId);Attachments
Add an attachments list to the request. Each entry has a filename, the file's bytes as a base64 string in content, and optionally a contentType (worked out from the file name if you leave it out).
import { readFile } from "node:fs/promises";
const invoice = await readFile("invoice.pdf");
await fetch("https://api.trysankofa.com/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SANKOFA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "Acme <hello@yourcompany.com>",
to: "customer@example.com",
subject: "Your invoice",
text: "Your invoice is attached.",
attachments: [
{ filename: "invoice.pdf", content: invoice.toString("base64"), contentType: "application/pdf" },
],
}),
});- Up to 10 files per email, 7 MB in total before base64 encoding.
contentmust be base64. Plain text or a file path is refused with400 ValidationError.- Executable and script files (such as
.exe,.batand.js) can't be emailed. Put them in a zip file or send a link instead. - An email with attachments still uses one credit per recipient.
Templates and bulk sending
A template is an email you save once and reuse. Write {{name}} wherever a value should go, in the subject or the body, and supply the values when you send. Create templates on the Templates page or with the API:
$ curl -X POST https://api.trysankofa.com/v1/templates \ -H "Authorization: Bearer esk_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "name": "order-shipped", "subject": "Your order {{order}} has shipped", "html": "<p>Hi {{name}}, your order {{order}} is on its way.</p>", "text": "Hi {{name}}, your order {{order}} is on its way." }'- Names use lower-case letters, digits, hyphens and underscores. Variable names use letters, digits and underscores.
- Give the template an HTML body, a plain-text body, or both.
- Values are escaped in the HTML body, so a customer name containing
<or&shows as typed and can't break your markup. PUT /v1/templates/:namereplaces a template andDELETEremoves it. An account can have up to 100.
Send one email from a template
Use templateName and templateData instead of subject, html and text:
$ curl -X POST https://api.trysankofa.com/v1/emails \ -H "Authorization: Bearer esk_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme <hello@yourcompany.com>", "to": "ama@example.com", "templateName": "order-shipped", "templateData": { "name": "Ama", "order": "A-1042" } }'Every variable in the template must have a value. If one is missing the API answers 400 TemplateDataError, names it, and sends nothing.
Send to many people at once
POST/v1/emails/bulk
Each recipient gets their own email, with their own values, and can't see the others. defaultTemplateData supplies values for any recipient that doesn't give its own.
$ curl -X POST https://api.trysankofa.com/v1/emails/bulk \ -H "Authorization: Bearer esk_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme <hello@yourcompany.com>", "templateName": "order-shipped", "defaultTemplateData": { "name": "there" }, "recipients": [ { "to": "ama@example.com", "templateData": { "name": "Ama", "order": "A-1042" } }, { "to": "kofi@example.com", "templateData": { "order": "A-1043" } } ] }'To do this without code, the Bulk send page in the dashboard takes a pasted list or a CSV file.
For a one-off send you don't need to save a template first. Leave out templateName and give the message itself as subject with html, text or both. Placeholders work there too, or leave them out to send everyone the same email.
The response has one result per recipient, in the order you sent them:
{
"results": [
{ "to": "ama@example.com", "status": "SUCCESS", "messageId": "010201a1..." },
{ "to": "kofi@example.com", "status": "FAILED", "error": "Cannot send to kofi@example.com: address is on the suppression list (reason: BOUNCE)." }
]
}- Up to 500 recipients per request. Split a longer list across several requests.
- One recipient failing doesn't stop the others, and a failed recipient isn't charged. Check each
status. - If credits or the API key's daily limit run out part-way, the remaining recipients are reported as failed with that reason, so you can send just those again later.
- A large request takes a while to answer, because it waits until every recipient has been tried. Allow about a minute for 500.
- Bulk sending needs a verified address or domain. The shared test sender can't be used.
- Only email people who asked to hear from you. See the Acceptable Use Policy.
Track delivery
Look up one email by the id you got when you sent it:
GET/v1/emails/:messageId
$ curl https://api.trysankofa.com/v1/emails/MESSAGE_ID \ -H "Authorization: Bearer esk_your_api_key"{
"messageId": "010201a12003b0ff-bb6f1fc0-7a46-4d2f-9549-e60f575cf3a9-000000",
"status": "delivered",
"events": [
{ "eventType": "Queued", "timestamp": "2026-10-09T09:34:35.212Z" },
{ "eventType": "Send", "timestamp": "2026-10-09T09:34:35.378Z" },
{ "eventType": "Delivery", "timestamp": "2026-10-09T09:34:36.901Z" }
]
}status is the furthest the email has got. Delivery events usually arrive within a few seconds of sending.
| Status | Meaning |
|---|---|
| queued | We accepted the email and handed it to the mail system. |
| sent | It left for the recipient's mail provider. |
| delivered | The recipient's mail provider accepted it. |
| opened | The recipient opened it (HTML emails only, and only when their mail app loads images). |
| clicked | The recipient clicked a link in it. |
| bounced | The recipient's mail provider refused it. A permanent bounce adds the address to the suppression list. |
| replied | The recipient wrote back, and you receive replies with Sankofa. |
| complained | The recipient marked it as spam. The address is added to the suppression list. |
GET /v1/emails lists your 200 most recent emails, newest first. In that list the id to use for lookups is sesMessageId. The Emails page in the dashboard shows the same information.
Receive replies
When someone answers an email you sent, Sankofa can receive the reply for you. It shows in the Inbox, under the email it answers on the Emails page, and can be sent to your webhooks. This needs a verified domain. Turn it on in the Domains page or with the API:
$ curl -X PUT https://api.trysankofa.com/v1/domains/yourcompany.com/inbound \ -H "Authorization: Bearer esk_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "mode": "subdomain" }'{
"domain": "yourcompany.com",
"mode": "subdomain",
"verified": false,
"replyAddress": "reply@reply.yourcompany.com",
"mxRecord": {
"type": "MX",
"name": "reply.yourcompany.com",
"value": "10 inbound-smtp.eu-west-1.amazonaws.com"
},
"otherMx": []
}Add the MX record from the response where you manage your DNS. Once it is in place, verified becomes true (check with GET on the same path) and every email you send from the domain automatically carries the reply address, so replies come to Sankofa.
Two ways to receive
| Mode | Mail arrives at | Use it when |
|---|---|---|
| subdomain | reply.yourcompany.com | Your domain already has mailboxes (Google Workspace, Zoho, or any other). They keep working untouched. |
| root | yourcompany.com | The domain has no email anywhere else. ALL mail for the domain then comes to Sankofa, to any address. |
Choosing root for a domain that has mailboxes elsewhere would cut them off, so the API refuses it with 409 ConfirmationRequired until you repeat the request with "confirmNoOtherMail": true. Send "mode": null to turn receiving off.
Read what arrived
$ curl https://api.trysankofa.com/v1/inbound \ -H "Authorization: Bearer esk_your_api_key"{
"retentionDays": 90,
"messages": [
{
"id": "5b2f0c1e-7a4d-4a52-9d0e-3f6c1b8e2a11",
"fromAddress": "ama@example.com",
"fromName": "Ama Mensah",
"toAddress": "reply@reply.yourcompany.com",
"subject": "Re: Your order has shipped",
"preview": "Thanks! When will it arrive?",
"inReplyTo": "010201a12003b0ff-bb6f1fc0-7a46-4d2f-9549-e60f575cf3a9-000000",
"kind": "reply",
"spam": false,
"attachments": [
{ "filename": "receipt.pdf", "contentType": "application/pdf", "size": 48213, "downloadable": true }
],
"receivedAt": "2026-10-12T13:00:15.000Z"
}
]
}GET /v1/inbound/:idreturns one message with its fulltextandhtml.DELETE /v1/inbound/:idremoves it.inReplyTois the id of the email being answered, when it can be worked out. That email's status becomesreplied, andGET /v1/emails/:messageIdlists its replies.kindisautofor out-of-office replies, bounce notices and other machine-sent mail, which don't count as a reply.- Messages our provider judges to be spam are kept but marked
spam. Messages carrying a virus are discarded. - Attachments are listed with their name, size and whether they are
downloadable. Fetch a file withGET /v1/inbound/:id/attachments/:index, where the index is its position in the list, starting at 0. Files on spam aren't kept. - Answer a message with
POST /v1/inbound/:id/reply, givingfrom(an address on your domain) andtext. It goes to the person who wrote, threaded under their message with their text quoted, and uses one credit. The Inbox has a reply box that does the same. - Received email is deleted after 90 days. Receiving doesn't use credits.
- If you show a received message's
htmlin your own app, treat it as untrusted and sandbox it.
Webhooks
Instead of asking for an email's status, you can have delivery events sent to your server as they happen. Register an endpoint on the Webhooks page or with the API. You choose the secret; use a long random string and keep a copy, because it isn't shown again.
$ curl -X POST https://api.trysankofa.com/v1/webhooks \ -H "Authorization: Bearer esk_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "url": "https://yourcompany.com/webhooks/sankofa", "secret": "a-long-random-string-you-generate" }'For each event we send a POST with a JSON body to your URL:
{
"messageId": "010201a12003b0ff-bb6f1fc0-7a46-4d2f-9549-e60f575cf3a9-000000",
"eventType": "delivered",
"timestamp": "2026-10-09T09:34:36.901Z",
"recipients": ["customer@example.com"]
}eventTypeis one ofsent,delivered,opened,clicked,bounced,complainedorreceived.timestampis when the event happened. An email can be opened or clicked more than once, and each time is a separate event.- Answer with any
2xxstatus to acknowledge. Otherwise we try again after 1, 2, 4 and 8 seconds, then give up on that event. - The same event can arrive more than once, and events can arrive out of order. Make your handler safe to repeat.
Received email
If you receive replies, each incoming message produces a received event. messageId is the email it answers, or an empty string when it isn't a reply to anything you sent. Fetch the full message with GET /v1/inbound/:id using received.id.
{
"messageId": "010201a12003b0ff-bb6f1fc0-7a46-4d2f-9549-e60f575cf3a9-000000",
"eventType": "received",
"timestamp": "2026-10-12T13:00:15.000Z",
"recipients": ["reply@reply.yourcompany.com"],
"received": {
"id": "5b2f0c1e-7a4d-4a52-9d0e-3f6c1b8e2a11",
"from": "ama@example.com",
"subject": "Re: Your order has shipped",
"automatic": false
}
}Check the signature
Every delivery carries an X-Webhook-Signature header of the form t=1791538475,v1=5f2b…. t is the time we signed it (Unix seconds) and v1 is an HMAC-SHA256, in hex, of {t}.{raw body} using your secret. Recompute it and compare before trusting the request:
import crypto from "node:crypto";
// rawBody must be the exact bytes we sent, as a string, before any JSON parsing.
export function isFromSankofa(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(signatureHeader.split(",").map((part) => part.split("=")));
if (!parts.t || !parts.v1) return false;
// Reject old deliveries so a captured request can't be replayed later.
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1, "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}Then call it at the top of your endpoint, before reading the event. For example, with Express:
import express from "express";
import { isFromSankofa } from "./sankofa-webhook.js";
const app = express();
// express.raw keeps the body as bytes, which the signature check needs.
app.post("/webhooks/sankofa", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
const signature = req.get("X-Webhook-Signature") ?? "";
if (!isFromSankofa(rawBody, signature, process.env.SANKOFA_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(rawBody);
if (event.eventType === "bounced") {
// ...stop emailing event.recipients
}
res.status(200).end();
});API keys and daily limits
Any API key can have a daily limit: the most emails it may send in a day. It is the simplest way to cap the damage if a key leaks, a script loops, or an AI agent misbehaves. Set it when you create a key on the API Keys page, or edit it later in the table there.
- Counted per recipient: an email to three people uses three.
- Resets at 00:00 UTC.
- A key with no limit can send as much as your credits allow.
- Limits can only be changed from the dashboard, so a key can't raise its own.
A send that would go over the limit is refused with 429. Nothing is sent and no credits are spent:
{
"error": "DailyLimitReached",
"message": "This API key has reached its daily limit of 50 emails. The limit resets at 00:00 UTC. It can be changed on the API Keys page of the dashboard."
}AI agents (MCP)
Sankofa has a Model Context Protocol (MCP) server, so AI assistants such as Claude Code and Cursor can send email and check delivery for you. Create an API key just for the agent, give it a daily limit, and connect:
$ claude mcp add --transport http sankofa \ https://api.trysankofa.com/mcp \ --header "Authorization: Bearer esk_your_api_key"Clients that take a JSON config (Cursor and others) use the same URL and header:
{
"mcpServers": {
"sankofa": {
"type": "http",
"url": "https://api.trysankofa.com/mcp",
"headers": {
"Authorization": "Bearer esk_your_api_key"
}
}
}
}| Tool | What it does |
|---|---|
| send_email | Sends one email from a sender your account has verified. |
| get_email_status | Returns an email's status and delivery events. |
| list_emails | Lists recent emails with their status. |
| get_credit_balance | Returns your credits and how much of the key's daily limit is used. |
| list_senders | Lists the addresses and domains the agent can send from. |
- The server only accepts API keys, so the key's daily limit always applies to what the agent sends.
- An agent can't verify senders, create or change keys, or buy credits.
- The server asks agents to confirm the recipient and content with you before sending. That is a request to the agent, not a guarantee; the daily limit is the hard stop.
Credits
Sending is prepaid. One credit sends one email to one recipient, and credits cost GHS 5 per 1,000. Buy them by card or mobile money on the Billing page. There is no subscription, and credits are returned automatically if an email fails to send.
Check your balance at any time:
$ curl https://api.trysankofa.com/v1/billing \ -H "Authorization: Bearer esk_your_api_key"The response includes emailCredits. When a send needs more credits than you have, the API answers 402 InsufficientCreditsError and sends nothing.
Suppression list
Addresses that permanently bounce or report an email as spam are added to a suppression list, and later sends to them are refused with 422 SuppressedRecipientError before any credit is spent. This protects delivery for everyone who sends through Sankofa. If an address was suppressed by mistake, write to info@trysankofa.com.
Errors
Errors use ordinary HTTP status codes and always have the same JSON shape. error is a stable name you can check in code; message is written for a person.
{
"error": "ValidationError",
"message": "from, to, and subject are required"
}| Status | Error | What it means |
|---|---|---|
| 400 | ValidationError | Something in the request is missing or malformed. The message says what. |
| 400 | MessageRejected | The mail system refused the message itself, for example because of an attachment it doesn't allow. Nothing was charged. |
| 400 | TemplateDataError | A variable the template uses has no value. The message names it. |
| 401 | Unauthorized | The API key is missing, wrong or revoked. |
| 402 | InsufficientCreditsError | Not enough credits for this send. Nothing was sent. |
| 403 | Forbidden | The sender isn't verified on your account, the test sender was used for someone else's address, or the action needs the dashboard. |
| 404 | NotFound | No such email, domain, sender or webhook on your account. |
| 409 | Conflict | The domain or address is already in use by another account. |
| 413 | PayloadTooLarge | The request is too large. A send can be up to 10 MB including attachments. |
| 422 | SuppressedRecipientError | The recipient is on the suppression list. Nothing was sent or charged. |
| 422 | UnverifiedDomainError | The sending domain hasn't finished verification yet. |
| 422 | DomainPolicyError | The address's domain requires domain verification rather than single-address verification. |
| 429 | DailyLimitReached | The API key's daily limit is used up. Nothing was sent or charged. |
| 429 | DailyQuotaExceededError | Sankofa as a whole is at its sending capacity for the day. Try again later. |
| 500 | InternalError | Something went wrong on our side. Credits for an email that didn't go out are returned automatically. |
Endpoint reference
All paths are relative to https://api.trysankofa.com and need the Authorization header.
| Method | Path | What it does |
|---|---|---|
| POST | /v1/emails | Send an email |
| GET | /v1/emails | List your 200 most recent emails |
| GET | /v1/emails/:messageId | Status and delivery events for one email |
| POST | /v1/emails/bulk | Send a template or a one-off message to up to 500 recipients |
| GET | /v1/templates | List your templates |
| POST | /v1/templates | Create a template |
| GET | /v1/templates/:name | Get one template |
| PUT | /v1/templates/:name | Replace a template |
| DELETE | /v1/templates/:name | Delete a template |
| GET | /v1/test-sender | The shared test sender and the one address you can email with it |
| GET | /v1/senders | List your single sender addresses |
| POST | /v1/senders | Add a sender address and email it a confirmation link |
| DELETE | /v1/senders/:email | Remove a sender address |
| GET | /v1/domains | List your domains |
| POST | /v1/domains | Add a domain and get its DNS records |
| GET | /v1/domains/:domain | Verification status of a domain |
| GET | /v1/domains/:domain/inbound | Receiving status of a domain and its DNS record |
| PUT | /v1/domains/:domain/inbound | Turn receiving on or off for a domain |
| GET | /v1/inbound | List received email |
| GET | /v1/inbound/:id | Read one received email in full |
| GET | /v1/inbound/:id/attachments/:index | Download an attachment of a received email |
| POST | /v1/inbound/:id/reply | Answer a received email |
| DELETE | /v1/inbound/:id | Delete a received email and its attachments |
| GET | /v1/webhooks | List your webhook endpoints |
| POST | /v1/webhooks | Register a webhook endpoint |
| DELETE | /v1/webhooks/:id | Remove a webhook endpoint |
| GET | /v1/api-keys | List your API keys with their limits and today's usage |
| GET | /v1/billing | Credit balance and pricing |
| POST | /mcp | MCP server for AI agents |
Something missing or unclear? Write to info@trysankofa.com and we'll help. Use of the API is covered by our Terms of Service and Acceptable Use Policy.