Documentation
Everything you need to send email with Sendscape
Getting started
Three steps from zero to your first delivered email.
Verify a domain
Add a domain and you get three CNAME records that set up email signing (DKIM). Create them at your DNS provider, and the dashboard checks automatically every 15 seconds until they resolve. Verification usually completes within minutes, but DNS propagation can take up to 48 hours.
Once verified, you can send from any address at that domain - and at its subdomains. Verifying example.com also lets you send from news.example.com. The Free plan includes 1 sending domain; paid plans include more (see Quotas and plans).
Your records and live verification status are on the Domains page.
Send via HTTP API
Send by POSTing JSON to https://api.sendscape.ai/v1/emails with your API key as a bearer token: Authorization: Bearer YOUR_API_KEY. Keys start with ss_ and are created on the API Keys page.
Request fields
| Field | Type | Required | Notes |
|---|---|---|---|
| from | string | Yes | Sender address, either "Acme <hello@yourdomain.com>" or a bare address. The domain must be verified on your account; a verified parent domain also covers its subdomains. onboarding@sendscape.ai is accepted only when every recipient is a sandbox address. |
| to | string | array | Yes | A single recipient, or an array of addresses. Everyone listed gets the same email and can see each other in the To field - to send each person a private copy, make one request per recipient. to + cc + bcc together may not exceed 50, and one request counts once against your quota regardless of recipient count. |
| cc | string | array | No | Carbon-copy recipients. Same cap and suppression rules as to. |
| bcc | string | array | No | Blind-copy recipients - never visible to the others. Same cap and suppression rules. |
| subject | string | Yes | Max 255 characters. |
| html | string | No* | HTML body. At least one of html or text is required. |
| text | string | No* | Plain-text body. Sending it alongside html helps deliverability. |
| reply_to | string | array | No | One or more Reply-To addresses (max 10). |
| headers | object | No | Up to 10 custom headers, e.g. {"X-Entity-Ref-ID": "order-42"}. Names use letters, digits and hyphens; values are one line. Headers the mail system owns (From, To, Cc, Bcc, Subject, Date, Message-ID, Content-*, List-Unsubscribe-Post, X-SES-*) are refused with invalid_header. List-Unsubscribe is allowed. |
| attachments | array | No | Up to 10 files as {filename, content, content_type?, content_id?}. content is base64; 10 MB decoded in total. Executables and scripts are refused. Set content_id to embed an image inline via cid:. Not accepted on the batch endpoint. |
| tags | array | No | Up to 10 {name, value} pairs (letters, digits, - and _, max 256 chars each). Returned on the email object and filterable with GET /v1/emails?tag=name:value. |
Example request
curl -X POST https://api.sendscape.ai/v1/emails \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "hello@yourdomain.com",
"to": ["recipient@example.com"],
"subject": "Hello from Sendscape",
"html": "<p>It works!</p>"
}'Success response
Accepted sends return 202. The id is the send-log entry you can find under Email Metrics.
HTTP/1.1 202 Accepted
{
"id": 42,
"message_id": "0107019064ab1234-56c78d90-...",
"status": "accepted"
}Errors
Errors use a consistent envelope: an error machine code plus a human-readable message.
| Status | Code | Meaning |
|---|---|---|
| 401 | missing_api_key | No Authorization header, or the token does not start with ss_. |
| 401 | invalid_api_key | The key was not found or has been revoked. |
| 401 | expired_api_key | The key passed its expiry date. Create a new one. |
| 403 | insufficient_scope | A sending-only key was used on a read endpoint. Use a full-access key. |
| 403 | key_domain_mismatch | The key is bound to a different sending domain than the from address. |
| 422 | (validation) | A field failed validation. Standard envelope with "message" and per-field "errors". |
| 422 | missing_body | Neither html nor text was provided. |
| 422 | domain_not_verified | The from domain is not verified on your account. |
| 422 | recipient_suppressed | A recipient is on your suppression list. |
| 422 | too_many_recipients | More than 50 recipients (to + cc + bcc) in one message. |
| 422 | invalid_header | A custom header uses a reserved name, a bad character, or a list instead of an object. |
| 422 | invalid_tag | A tag name or value is outside letters, digits, - and _. |
| 422 | invalid_attachment | An attachment has no filename, is not valid base64, or has a bad content type. |
| 422 | attachments_too_large | Attachments exceed 10 MB decoded in total. |
| 422 | attachment_type_not_allowed | Executable or script attachments are refused. |
| 422 | attachments_not_allowed_in_batch | Attachments are only accepted on POST /v1/emails. |
| 422 | invalid_batch | The batch body is not a JSON array of 1 to 100 messages. |
| 422 | batch_validation_failed | One or more batch messages failed validation. Nothing was sent; see errors by index. |
| 422 | invalid_cursor | The list cursor was not one this API issued. |
| 422 | idempotency_key_reused | The Idempotency-Key was already used with a different request body. |
| 422 | idempotency_key_invalid | The Idempotency-Key is longer than 256 characters. |
| 422 | sandbox_mixed_recipients | Sandbox and real recipients cannot be mixed in one message. |
| 422 | invalid_sandbox_address | Only delivered@, bounced@, complained@ and suppressed@sandbox.sendscape.ai exist. |
| 403 | account_paused | Your account is paused and cannot send. |
| 403 | tenant_paused | The mail provider paused this account for reputation. Contact support. |
| 404 | not_found | No email with that id on your account. |
| 409 | idempotency_in_progress | A request with the same Idempotency-Key is still being processed. Retry shortly. |
| 413 | payload_too_large | The request body is over the size limit (about 15 MB). |
| 429 | quota_exceeded | Your daily or monthly sending quota has been reached. Retry after it resets. |
| 429 | rate_limited | Too many requests in a minute (600 sends, 20 batches, 300 reads). Retry after the Retry-After header. |
| 502 | ses_error | The upstream mail provider rejected the message. Your quota is refunded - safe to retry. |
HTTP/1.1 422 Unprocessable Content
{
"error": "domain_not_verified",
"message": "The sending domain 'example.com' is not verified for this account."
}Advanced fields
cc, bcc, a reply-to list, custom headers, tags, an inline attachment and an Idempotency-Key, all in one request. Attachments are capped at 10 MB decoded per message.
curl -X POST https://api.sendscape.ai/v1/emails \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-42-receipt" \
-d '{
"from": "Acme <billing@yourdomain.com>",
"to": ["customer@example.com"],
"cc": ["accounts@example.com"],
"bcc": "archive@yourdomain.com",
"reply_to": ["support@yourdomain.com"],
"subject": "Receipt for order 42",
"html": "<p>Thanks! <img src=\"cid:logo\"></p>",
"headers": { "X-Entity-Ref-ID": "order-42" },
"tags": [{ "name": "category", "value": "receipt" }],
"attachments": [{ "filename": "logo.png", "content": "iVBORw0KGgo...", "content_type": "image/png", "content_id": "logo" }]
}'Idempotency
Add an Idempotency-Key header (any string, unique per logical send) and a retried request returns the original outcome instead of sending twice. Remembered for 24 hours per API key; quota and pause errors are not remembered, so the same key can be retried later.
POST /v1/emails
Idempotency-Key: order-42-receipt
# first call -> 202 {"id": 42, ...}
# same key + same body within 24 h -> the same 202 body, plus:
Idempotency-Replayed: true
# same key + a different body -> 422 idempotency_key_reusedRetrieve and list
GET /v1/emails/{id} returns the email object; GET /v1/emails lists yours newest first with a cursor. Both need a full-access key.
HTTP/1.1 200 OK
{
"id": 42,
"message_id": "0107019064ab1234-56c78d90-...",
"from": "hello@yourdomain.com",
"to": ["user@example.com"],
"cc": [],
"bcc": [],
"reply_to": ["support@yourdomain.com"],
"subject": "Your receipt",
"tags": [{ "name": "category", "value": "receipt" }],
"status": "delivered",
"error": null,
"channel": "api",
"created_at": "2026-09-07T10:15:02+08:00",
"last_event_at": "2026-09-07T10:15:09+08:00",
"bounce_type": null,
"opened_at": "2026-09-07T10:21:40+08:00",
"clicked_at": null,
"open_count": 1,
"click_count": 0
}| Parameter | Meaning |
|---|---|
| limit | Page size, 1-100 (default 20). |
| cursor | The next_cursor from the previous page. Omit for the first page. |
| status | accepted (includes delayed), delayed, delivered, bounced, complained, failed (includes rejected) or rejected. |
| tag | name or name:value - only emails carrying that tag. |
| to | Exact recipient address (the first To). |
| from | Exact sender address. |
| since / until | Date (YYYY-MM-DD, whole day) or ISO 8601 timestamp bounds. |
Send a batch
POST /v1/emails/batch takes an array of up to 100 messages (no attachments). Every message is validated before any is sent; a valid batch is sent in order with one result per message.
POST /v1/emails/batch
Idempotency-Key: nightly-2026-09-07
[
{ "from": "hello@yourdomain.com", "to": ["a@example.com"], "subject": "Hi A", "text": "..." },
{ "from": "hello@yourdomain.com", "to": ["b@example.com"], "subject": "Hi B", "text": "..." }
]Sandbox addresses
Exercise the whole pipeline - log rows, statuses, webhooks - without real mail or quota. Works from any verified domain, or from onboarding@sendscape.ai before you have one.
| Recipient | What happens |
|---|---|
| delivered@sandbox.sendscape.ai | Accepted, then marked delivered. Webhooks: email.sent, email.delivered. |
| bounced@sandbox.sendscape.ai | Accepted, then a simulated permanent bounce. Nothing is added to your suppression list. |
| complained@sandbox.sendscape.ai | Accepted, then a simulated spam complaint. Nothing is suppressed. |
| suppressed@sandbox.sendscape.ai | Refused with 422 recipient_suppressed, so your client can exercise that branch. |
API key permissions
A key is full-access by default. A sending-only key can call the send endpoints but not read your log, and a key can be bound to one sending domain.
| Permission | Can send | Can read emails | Notes |
|---|---|---|---|
| full_access (default) | Yes | Yes | Every key created before permissions existed. |
| sending_access | Yes | No | Read endpoints answer 403 insufficient_scope. Embed this one in apps that only send. |
Send via SMTP
Anything that speaks SMTP - Laravel, WordPress, legacy apps, IoT devices - can send through Sendscape without code changes. Create a credential on the SMTP Credentials page and plug in these settings:
| Setting | Value |
|---|---|
| Host | smtp.sendscape.ai |
| Port | 587 |
| Encryption | STARTTLS |
| Username | ss_... (shown on the credentials page) |
| Password | Shown once when you create the credential |
SMTP goes through the same pipeline as the HTTP API: the from domain must be verified, suppressed recipients are rejected, and sends count against the same quota.
MAIL_MAILER=smtp
MAIL_HOST=smtp.sendscape.ai
MAIL_PORT=587
MAIL_USERNAME=YOUR_SMTP_USERNAME
MAIL_PASSWORD=•••••••• # shown once when you create the credential
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=hello@yourdomain.comConnect n8n
Use Sendscape in an n8n workflow with built-in nodes - no custom node required. Either the HTTP Request node with an API key, or the Send Email node with an SMTP credential.
n8n → "HTTP Request" node
─────────────────────────────────
Method: POST
URL: https://api.sendscape.ai/v1/emails
Authentication: Generic Credential → Header Auth
• Name: Authorization
• Value: Bearer YOUR_API_KEY
Send Body: ON (Body Content Type: JSON)
Specify Body: Using JSON
JSON body:
{
"from": "hello@yourdomain.com",
"to": ["recipient@example.com"],
"subject": "Hello from n8n",
"html": "<p>Sent from an n8n workflow.</p>"
}Webhooks
Register an HTTPS endpoint under Webhooks and Sendscape POSTs a signed JSON event every time one of your emails moves - delivered, bounced, complained and more. Answer any 2xx within 10 seconds; failures are retried for about a day.
| Event | When | Extra fields in data |
|---|---|---|
| email.sent | The mail servers accepted the message. | - |
| email.delivered | The receiving server took it (once per message). | delivery.timestamp, delivery.smtp_response, delivery.recipients |
| email.delivery_delayed | Temporarily refused; the provider is retrying. One event per retry notice; settles to delivered or bounced. | delay.type, delay.expires_at, delay.recipients[{address, status, diagnostic_code}], delay.timestamp |
| email.bounced | Refused by the receiving server. bounce.type Permanent = the address is now suppressed; Transient = soft bounce (mailbox full, expired), nothing suppressed. | bounce.type, bounce.sub_type, bounce.diagnostic_code, bounce.recipient, bounce.timestamp |
| email.complained | Marked as spam; the address is suppressed. | complaint.feedback_type, complaint.recipient, complaint.timestamp |
| email.failed | The mail provider returned an error (quota refunded), or later dropped the message as malicious content (not refunded). | error |
| email.opened | The recipient loaded the tracking pixel (HTML mail, domain with open tracking on). One event per open. | open.ip, open.user_agent, open.timestamp, open.bot |
| email.clicked | The recipient followed a tracked link (domain with click tracking on). One event per click. | click.link, click.ip, click.user_agent, click.timestamp, click.bot |
| suppression.created | An address was added to your suppression list (automatically or by you). | data is {id, address, reason, detail, created_at} |
| suppression.deleted | You removed an address from the list. | same shape |
A delivery
POST https://example.com/webhooks/sendscape
Content-Type: application/json
User-Agent: Sendscape-Webhooks/1.0
Sendscape-Event: email.bounced
Sendscape-Event-Id: evt_3f9c2a1b7d4e5f60718293a4b5
Sendscape-Delivery-Id: 1187
Sendscape-Timestamp: 1789000000
Sendscape-Signature: v1=8a4d1c...
{
"type": "email.bounced",
"id": "evt_3f9c2a1b7d4e5f60718293a4b5",
"created_at": "2026-09-07T10:15:09+08:00",
"data": {
"id": 42,
"message_id": "0107019064ab1234-56c78d90-...",
"from": "hello@yourdomain.com",
"to": ["user@example.com"],
"cc": [],
"bcc": [],
"reply_to": [],
"subject": "Your receipt",
"tags": [{ "name": "category", "value": "receipt" }],
"status": "bounced",
"error": null,
"channel": "api",
"created_at": "2026-09-07T10:15:02+08:00",
"last_event_at": "2026-09-07T10:15:09+08:00",
"bounce_type": "Permanent",
"opened_at": null,
"clicked_at": null,
"open_count": 0,
"click_count": 0,
"bounce": {
"type": "Permanent",
"sub_type": "General",
"diagnostic_code": "smtp; 550 5.1.1 user unknown",
"recipient": "user@example.com",
"timestamp": "2026-09-07T02:15:09.000Z"
}
}
}Verify the signature
Sendscape-Signature is v1= plus HMAC-SHA256 over {timestamp}.{raw body} with your endpoint secret. Hash the exact bytes you received, compare in constant time, and reject timestamps more than 5 minutes off.
$secret = getenv('SENDSCAPE_WEBHOOK_SECRET');
$body = file_get_contents('php://input'); // the raw bytes, never re-encoded
$timestamp = $_SERVER['HTTP_SENDSCAPE_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_SENDSCAPE_SIGNATURE'] ?? '';
$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
if (abs(time() - (int) $timestamp) > 300 || ! hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
// $event['type'] e.g. "email.bounced"; $event['data'] is the email object
http_response_code(200);Retries
| Attempt | Delay after the previous failure |
|---|---|
| 1 | immediately |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 15 minutes |
| 5 | 1 hour |
| 6 | 2 hours |
| 7 | 4 hours |
| 8 | 8 hours (last; ~26 hours in total) |
After 50 consecutive final failures the endpoint is switched off and you are emailed; re-enable it from the dashboard once it is reachable. Logs are kept 30 days on Free and 90 on paid plans; webhook delivery history for 30.
Broadcasts (marketing)
Broadcasts send one email to your whole audience - newsletters, promos, announcements. Everything happens in the dashboard (no API needed): build your list under Contacts, compose and send under Broadcasts.
city or plan). Organize them with your own groups like “FB Leads” or “Customers”, or build a segment - a saved rule such as “city is Kuala Lumpur and not in group Customers” that is evaluated live every time you send.{{first_name|there}}, or any property key) from the Insert tag menu. Preview shows exactly what one real contact will receive; Send test delivers it to your own inbox, marked [TEST], with the unsubscribe link disarmed. Target all contacts, groups or a segment - the live counter shows exactly how many people will receive it.Merge tags
| Tag | Renders as |
|---|---|
| {{first_name}} | The contact's first name |
| {{last_name}} | The contact's last name |
| {{email}} | The contact's email address |
| {{unsubscribe_url}} | Their personal unsubscribe link (HTML mode - otherwise the footer carries it) |
| {{city}} | Any custom property, by its key - set on the contact or imported as an extra CSV column |
| {{first_name|there}} | A fallback after the pipe is used when the value is blank |
Tags work in the subject and the body. Contact values are HTML-escaped in the body, so a name like <b>Ana</b> shows as text rather than becoming markup; a fallback is your own template and is left as written. An unknown tag with no fallback renders as nothing, never as raw braces.
Unsubscribe, handled for you
Every broadcast automatically carries an unsubscribe footer link and the one-click unsubscribe header that Gmail and Yahoo require from bulk senders - there is nothing to configure. When someone unsubscribes, future broadcasts skip them automatically, but they still receive your transactional email (receipts, OTPs) - unsubscribes and the suppression list are deliberately separate. A recipient who marks a broadcast as spam is unsubscribed automatically too.
Rules of the road
- Broadcasts go only to subscribed contacts, deduplicated across groups.
- A test send is a real email through your domain (it counts like any send for reputation) but writes no recipient row and never spends the free-tier monthly cap.
- If you hold more contacts than your marketing plan allows (for example after a downgrade), adding contacts and sending broadcasts pause until you upgrade or trim the list - contacts are never deleted.
- The free marketing tier sends up to 4,000 broadcast emails per month and adds a small “Sent with Sendscape” line to the footer - both lifted on paid marketing plans.
Plans, quotas and extras
Transactional plans
| Plan | Price | Emails / month | Emails / day | Domains |
|---|---|---|---|---|
| Free | RM0 | 3,000 | 100 | 1 |
| Starter | RM69/mo | 50,000 | 5,000 | 10 |
| Growth | RM299/mo | 100,000 | 10,000 | 50 |
Quotas count messages, not recipients - one send to 50 addresses uses 1 from your quota (and all 50 share the same To field; see the to field above). The daily quota resets at midnight (Malaysia time); the monthly quota resets on your billing anniversary, not the calendar month. Sends over quota return 429 quota_exceeded and nothing is queued - retry after the reset. Manage your plan under Usage & Billing.
Marketing plans
| Plan | Price | Contacts | Broadcast sending |
|---|---|---|---|
| Free | RM0 | 1,000 | 4,000 emails / month |
| Starter | RM129/mo | 5,000 | Unlimited |
| Growth | RM449/mo | 25,000 | Unlimited |
Marketing is priced by contacts stored, not emails sent - broadcasts never consume your transactional quota. The two plans are separate and mix freely: free transactional with a paid marketing plan works, and so does the reverse.
Extra emails (pay-as-you-go)
On paid transactional plans you can keep sending past your monthly limit: enable Extra emails under Usage & Billing → Extras, and each additional 1,000 emails deducts RM 4.00 from your credits automatically. Your daily limit always applies, and nothing is charged unless you opt in. The free plan is a hard stop at its monthly quota.
Send statuses
Every message gets a status on the Email Metrics page. Most sends start as Accepted and move on once the receiving server reports back.
| Status | What it means |
|---|---|
| Accepted | We handed the message to the mail servers and it is on its way. This is the normal result of a successful send. |
| Delayed | The receiving server refused the message for now (mailbox full, greylisting, a busy server) and the mail provider is retrying for up to 14 hours. It settles to delivered or bounced; nothing is suppressed. Each retry notice fires an email.delivery_delayed webhook. |
| Delivered | The receiving mail server confirmed it took the message for the recipient. Most sends go from Accepted to Delivered within seconds. |
| Bounced | The receiving server refused the message. A permanent bounce (the address does not exist, the mailbox is closed) adds the address to your suppression list automatically. A soft bounce (mailbox full, message expired in transit) does not - the email is marked bounced and you may send to the address again later. Webhooks carry bounce.type to tell them apart. |
| Complained | The recipient marked the email as spam. The address is suppressed automatically, and a high complaint rate can pause your account. |
| Rejected | Sendscape refused the message before it left - over quota, a suppressed recipient, an unverified sending domain, or a paused account. Nothing was sent and no quota was used. |
| Failed | The mail provider returned an error while we were sending. Your quota is refunded, so it is safe to retry. |
The Failed tab lists both Failed and Rejected messages - they are the two ways a send can not go out. Click any row to open its details, which include the exact reason. The codes behind a rejection are the same ones the API returns: see Errors.
Bounced and Complained addresses go straight onto your suppression list - see Suppressions and deliverability for what that means for your account.
Broadcast emails appear here too (channel broadcast). Each broadcast's own page adds two fan-out states you will not see in Email Metrics: Pending (queued, not yet sent) and Skipped (the contact unsubscribed or was suppressed by the time we reached them).
Suppressions and deliverability
When an email hard-bounces or the recipient marks it as spam, that address is automatically added to your suppression list. Later sends to it are rejected with recipient_suppressed instead of being silently dropped, so your logs stay honest. Review and manage the list under Suppressions.
To protect deliverability for everyone, accounts with a bounce rate above 5% or a complaint rate above 0.1% (measured after 50 sends) are paused automatically.
Keeping your rates healthy is mostly list hygiene:
- Send only to recipients who opted in - never to purchased or scraped lists.
- Remove addresses that have not engaged in months before large sends.
- Include a plain-text part alongside your HTML.
- Use a subdomain (like mail.yourdomain.com) for transactional mail to isolate reputation.
- Broadcasts count toward the same bounce and complaint rates - a spam complaint on a newsletter hurts your account exactly like one on a receipt (and auto-unsubscribes that contact).
Using AI coding assistants
If you build with Claude, Cursor or a similar assistant, install the Sendscape skill and it will write integration code against the real API instead of guessing. The bundle is generated from this page, so the endpoint, request fields and error codes are always the ones documented above.
# Install the Sendscape skill into your project
mkdir -p .claude/skills
curl -L https://sendscape.ai/sendscape-skill.zip -o /tmp/sendscape-skill.zip
unzip -o /tmp/sendscape-skill.zip -d .claude/skills
# Or install it for every project on this machine:
# unzip -o /tmp/sendscape-skill.zip -d ~/.claude/skills
# Then just ask, and it loads on its own:
# "send a welcome email with Sendscape when a user signs up"