Sendpit Developer Documentation
1. Introduction
Sendpit is an email testing platform designed for development and QA workflows. It provides isolated SMTP mailboxes that capture outgoing emails, allowing teams to test email functionality without sending messages to real recipients.
Sendpit offers a REST API for programmatic access to messages, search, and webhooks. Its authenticated browser UI also uses Reverb to refresh an open mailbox in real time.
Quick Start: If you just need to configure SMTP and send test emails, see the Integration Guide for language-specific examples (PHP, Node.js, Python, Ruby, Java, Go).
Who This Documentation Is For
- Backend developers integrating email sending into applications
- QA engineers validating email content and delivery
- DevOps teams setting up CI/CD pipelines with email verification
- Technical teams building automation around email testing
Common Use Cases
- Development Testing: Capture emails from local or staging environments
- CI/CD Integration: Verify transactional emails as part of automated test suites using the REST API and Wait endpoint
- QA Verification: Inspect email content, headers, attachments, and spam scores via API
- Webhook-Driven Automation: Trigger external workflows when emails are received
2. Getting Started
Creating an Organization
- Sign up at
https://sendpit.com/register - An organization is automatically created for your account
- You become the organization owner with full administrative access
Organizations are the top-level container for all resources. Team members, mailboxes, and billing are scoped to the organization.
Creating a Mailbox
- Navigate to Mailboxes in the dashboard
- Click Create Mailbox
- Enter a descriptive name (e.g., "Staging Environment", "CI Pipeline")
- Sendpit generates unique SMTP credentials automatically
Each mailbox receives:
- Username: a unique mailbox SMTP username beginning with
mb_ - Password: a unique mailbox SMTP password
- SMTP Host:
smtp.sendpit.com - SMTP Port:
587or2525(STARTTLS), or465(implicit TLS)
Use 587 by default. Port 2525 is a non-standard compatibility fallback for
networks that block standard submission ports; select it explicitly and keep
STARTTLS enabled. IANA assigns TCP/2525 to ms-v-worlds, not SMTP.
Sending a Test Email
Configure your application's SMTP settings:
MAIL_MAILER=smtp
MAIL_HOST=smtp.sendpit.com
MAIL_PORT=587
MAIL_USERNAME=mb_your_mailbox_username
MAIL_PASSWORD=your_mailbox_smtp_password
MAIL_SCHEME=smtp
MAIL_REQUIRE_TLS=true
In Laravel 12, ensure the SMTP mailer in config/mail.php includes
'require_tls' => env('MAIL_REQUIRE_TLS', true) so STARTTLS failure is fatal.
Send an email using your application's normal email functionality. The message will appear in Sendpit within seconds.
Viewing Received Emails
- Navigate to the mailbox in the dashboard
- Emails are listed in reverse chronological order
- Click any email to view full content, headers, and attachments
3. Authentication & API Access
Credentials
The REST API and SMTP share one set of credentials. Every mailbox has an SMTP username and password, and those same values authenticate API requests over HTTP Basic. There is no separate API token to create or manage.
curl -s "https://sendpit.com/api/v1/messages" \
-u "mb_a1b2c3d4e5f6:your-smtp-password"
Sending the header directly is equivalent:
Authorization: Basic <base64 of "username:password">
Always call the API over HTTPS. Basic authentication sends the password on every request, and it is the same password your SMTP clients use.
Finding Your Credentials
- Open the Sendpit dashboard
- Navigate to the mailbox you want API access for
- Go to Settings > SMTP credentials
Regenerating a mailbox's credentials rotates SMTP and API access together: the previous password stops working for both. That is also the only way to revoke API access, so treat a leaked password as something that has to be rotated everywhere your applications send mail, not just where they read it.
Scope
Credentials identify a single mailbox, so every endpoint operates on that mailbox. There is nothing to configure, and one mailbox's credentials cannot reach another's messages, webhooks, or attachments.
Within that mailbox the credentials carry full authority, including deleting individual messages and clearing the mailbox. This does not depend on the dashboard role of whoever holds them: the credential is the authorization, the same way it is over SMTP. Share it as you would an SMTP password.
Plan Gating
API access is gated by your organization's plan:
| Plan | API access | Requests per minute | Concurrent waits / mailbox |
|---|---|---|---|
| Free | No | - | - |
| Basic | No | - | - |
| Pro | Yes | 120 | 5 |
| Max | Yes | 300 | 10 |
API access is available on Pro and above. Every API endpoint returns 403 with
plan_required on any other plan. The rate limit applies per organization, so
mailboxes in the same organization share the quota.
Failed Authentication
Until a throttle counter is reached, every rejection returns 401 with
unauthenticated and a WWW-Authenticate: Basic header. Missing, malformed,
unknown and wrong credentials are deliberately indistinguishable, so a 401
never confirms whether a username exists. Past a counter the response becomes
429; nothing else about the rejection changes.
Repeated failures are throttled on three counters: 20 failures per 15 minutes per username and per mailbox, and 100 per 15 minutes per client address. Which counters a failure lands on depends on how far it got:
| Failure | Counters charged |
|---|---|
| Missing or malformed username | address |
| Username with no mailbox behind it | username, address |
| Real username, wrong password | mailbox, address |
So a stale password for a mailbox that exists never touches the username counter, and probing for usernames never touches the mailbox counter.
Two properties worth designing around:
- The correct password is always accepted, even when the mailbox counter is exhausted. Someone else's failed attempts cannot lock you out of your own mailbox.
- The client address counter is shared by everything behind that address. A CI
runner or office NAT with many callers behind it accumulates their failures
together, so a suite left running with a stale password can eventually
produce
429for unrelated callers at the same address.
A successful authentication clears the username and mailbox counters. The address counter is left standing.
Disabled Mailboxes
A disabled mailbox, or one whose organization is disabled, returns 403 with
mailbox_disabled on every endpoint, exactly as SMTP refuses it.
4. Mailboxes
What a Mailbox Represents
A mailbox is an isolated SMTP endpoint that:
- Accepts emails via SMTP on ports 587/2525 (STARTTLS) or 465 (implicit TLS)
- Stores messages for later inspection
- Can trigger webhooks on email receipt
- Has its own access control (team members can be granted per-mailbox access)
Address Format
Emails sent to a mailbox can use any address format:
anything@sendpit.com # Routed by SMTP credentials
user@yourdomain.com # Captured based on SMTP auth
noreply@staging.example.org # Any sender/recipient works
The routing is determined by SMTP authentication, not the email address.
Per-Mailbox Behavior
Each mailbox maintains:
- Independent email storage
- Separate webhook configurations
- Individual access control lists
- Isolated credential sets
Lifecycle Considerations
- Deletion: Deleting a mailbox permanently removes all associated emails
- Credential Regeneration: Invalidates existing SMTP credentials immediately
- Plan Downgrade: Excess mailboxes are disabled automatically, least recently used first; disabled mailboxes reject new emails. Re-enable a mailbox manually up to your plan's limit (disable another one first when you are at the limit). Upgrading never re-enables a mailbox automatically.
5. Emails & Attachments
Compact Schema (List View)
When listing messages, each email includes these fields:
| Field | Type | Description |
|---|---|---|
id |
string | Unique message identifier |
message_id |
string|null | RFC 5322 Message-ID header |
from |
object | {address, name} - sender |
to |
array | [{address, name}] - recipients |
cc |
array | [{address, name}] - CC recipients |
subject |
string | Email subject line |
received_at |
string | ISO 8601 timestamp |
size_bytes |
integer|null | Total message size in bytes |
is_read |
boolean | Whether the email has been viewed |
has_attachments |
boolean | Whether attachments are present |
attachment_count |
integer | Number of attachments |
has_codes |
boolean | Whether OTP/verification codes were extracted |
has_links |
boolean | Whether links were extracted |
spam_score |
number|null | Spam analysis score |
spam_threshold |
number|null | Threshold used by an exact or estimated result |
is_spam |
boolean|null | Exact classification; null while unknown or estimated |
spam_status |
string | not_analyzed, pending, estimated, clean, spam, failed, or skipped |
Full Schema (Detail View)
The detail view includes all compact fields plus:
| Field | Type | Description |
|---|---|---|
in_reply_to |
string|null | In-Reply-To header value |
bcc |
array | [{address, name}] - BCC recipients |
is_starred |
boolean | User-applied star flag |
labels |
array | Applied label IDs |
bodies.text |
string|null | Plain text body |
bodies.html |
string|null | HTML body |
bodies.text_size_bytes |
integer|null | Text body size |
bodies.html_size_bytes |
integer|null | HTML body size |
headers |
object | Full parsed email headers. Lowercased names, each mapping to an array of values |
attachments |
array | See attachment object below |
extractions.codes |
array | Extracted OTP/verification codes |
extractions.links |
array | Extracted links |
spam.score |
number|null | Spam score |
spam.threshold |
number|null | Spam threshold |
spam.is_spam |
boolean|null | Spam classification |
spam.is_analyzed |
boolean | Whether spam analysis completed |
spam.status |
string | Explicit spam-analysis state |
spam.analyzed_at |
string|null | ISO 8601 analysis timestamp |
content_hash |
string|null | Content hash for deduplication |
parse_error |
string|null | Why MIME parsing failed, or null when no parse failure was recorded |
Attachment Object
Each attachment in the attachments array:
| Field | Type | Description |
|---|---|---|
index |
integer | Zero-based index |
filename |
string|null | Original filename |
content_type |
string|null | MIME type |
size_bytes |
integer|null | File size in bytes |
disposition |
string|null | MIME disposition (attachment or inline) |
content_id |
string|null | Normalized Content-ID for inline/related parts |
download_url |
string | API URL to download the file |
Attachment Access
- Attachments are accessible only through authenticated API requests
- Download URLs return the extracted attachment bytes directly after authorization
- Attachments are not publicly accessible
6. Webhooks
Overview
Webhooks provide real-time HTTP notifications when emails are received, deleted, or cleared. Instead of polling, your application receives a POST request immediately after an event occurs.
Availability & Plans
| Plan | Webhooks | Endpoints per mailbox |
|---|---|---|
| Free | No | - |
| Basic | No | - |
| Pro | Yes | 5 |
| Max | Yes | 20 |
Creating a Webhook
Webhooks can be created via the dashboard or the REST API (see the webhook endpoints in the endpoint reference).
Via Dashboard:
- Navigate to a mailbox in the dashboard
- Open Settings > Webhooks
- Click Add Webhook
- Enter your HTTPS endpoint URL
- Optionally add a description
- Save the webhook
Upon creation, Sendpit generates a unique signing secret. Store this secret securely - it's required for signature verification.
Requirements:
- Endpoint must use HTTPS (HTTP is rejected)
- Endpoint must respond within 10 seconds
- Endpoint must return a 2xx status code
Event Types
| Event | Description | Payload Version |
|---|---|---|
message.received |
New email arrives | v1, v2 |
message.deleted |
Single message deleted | v2 only |
messages.cleared |
All messages cleared from mailbox | v2 only |
Webhook Payload
New webhooks default to payload version 2. This is the payload sent for a
message.received event:
{
"id": "1742049138000-0",
"type": "message.received",
"mailbox_id": 15,
"occurred_at": "2026-03-15T14:32:18+00:00",
"data": {
"id": "65a1b2c3d4e5f67890abcdef",
"message_id": "<abc123@mail.example.com>",
"from": {
"address": "sender@example.com",
"name": null
},
"to": [{
"address": "recipient@yourdomain.com",
"name": null
}],
"cc": [],
"subject": "Order Confirmation #12345",
"received_at": "2026-03-15T14:32:17+00:00",
"size_bytes": 18432,
"is_read": false,
"has_attachments": false,
"attachment_count": 0
}
}
Version 1 remains available for existing integrations and uses the legacy
event, timestamp, mailbox, and email fields. The payload contains
metadata only. Email body content and attachments are not included. Use the
REST API to retrieve full message content.
Security & Signature Verification
Every webhook request includes cryptographic signatures for verification.
Headers Sent:
| Header | Description | Format | Example |
|---|---|---|---|
X-Sendpit-Signature |
HMAC-SHA256 signature for payload verification | sha256=<hex> |
sha256=a1b2c3... |
X-Sendpit-Timestamp |
Unix timestamp when request was sent | Integer (seconds) | 1705329138 |
X-Sendpit-Webhook-Id |
Webhook configuration ID from your dashboard | Integer | 42 |
X-Sendpit-Event |
Event type that triggered the webhook | String | message.received |
Content-Type |
Payload content type | MIME type | application/json |
User-Agent |
Sendpit webhook client identifier | String | Sendpit-Webhook/1.0 |
Signature Verification (Recommended):
import hmac
import hashlib
import time
def verify_signature(secret: str, timestamp: str, payload: str, signature: str) -> bool:
# Check timestamp is within 5 minutes (replay protection)
current_time = int(time.time())
request_time = int(timestamp)
if abs(current_time - request_time) > 300:
return False
# Compute expected signature
message = f"{timestamp}.{payload}"
expected = "sha256=" + hmac.new(
secret.encode(),
message.encode(),
hashlib.sha256
).hexdigest()
# Constant-time comparison
return hmac.compare_digest(expected, signature)
const crypto = require('crypto');
function verifySignature(secret, timestamp, payload, signature) {
// Check timestamp is within 5 minutes
const currentTime = Math.floor(Date.now() / 1000);
const requestTime = parseInt(timestamp, 10);
if (Math.abs(currentTime - requestTime) > 300) {
return false;
}
// Compute expected signature
const message = `${timestamp}.${payload}`;
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(message)
.digest('hex');
// Constant-time comparison
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
function verifySignature(string $secret, string $timestamp, string $payload, string $signature): bool
{
// Check timestamp is within 5 minutes
$currentTime = time();
$requestTime = (int) $timestamp;
if (abs($currentTime - $requestTime) > 300) {
return false;
}
// Compute expected signature
$message = $timestamp . '.' . $payload;
$expected = 'sha256=' . hash_hmac('sha256', $message, $secret);
// Constant-time comparison
return hash_equals($expected, $signature);
}
Delivery, Retries, and Failure Behavior
Retry Schedule:
If delivery fails, Sendpit retries with exponential backoff:
| Attempt | Delay After Failure |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 15 minutes |
| 5 | 1 hour |
Retriable Status Codes: 408, 429, 500, 502, 503, 504
Retriable Failures: Network and timeout failures, plus the retriable HTTP
status codes above. Other 4xx responses fail immediately.
Auto-Disable Behavior: After 10 consecutive failures, the webhook is automatically disabled. Re-enable it via the dashboard or the API.
Webhook Logs & Retention
Each webhook delivery attempt is logged with timestamp, HTTP status code, response time, error message, and delivery status (success, failed, retrying).
Retention: Logs are retained for 30 days and then permanently deleted.
7. REST API Reference
Base URL
https://sendpit.com/api/v1
Required Headers
| Header | Value | Required |
|---|---|---|
Authorization |
Basic {base64 of username:password} |
Yes |
Accept |
application/json |
Recommended |
Content-Type |
application/json |
For POST/PUT requests |
Error Responses
Endpoint-specific operational errors commonly use this structure:
{
"error": {
"code": "not_found",
"message": "Message not found.",
"details": []
}
}
Authentication, request validation, and framework rate-limit failures can use Laravel's standard JSON shapes instead:
{"message": "You must be logged in to access this resource."}
{
"message": "The from.email field is required.",
"errors": {"from.email": ["The from.email field is required."]}
}
Use the HTTP status and message for all failures. Only rely on error.code
when an endpoint explicitly returns the error object.
Error Codes
| Code | HTTP Status | Description |
|---|---|---|
unauthenticated |
401 | The credentials were missing, malformed or not accepted |
mailbox_disabled |
403 | The mailbox, or the organization that owns it, is disabled |
unauthorized |
403 | The credentials cannot access this resource |
plan_required |
403 | Feature not available on your plan |
plan_limit |
403 | Plan quota exceeded (e.g., max webhooks) |
not_found |
404 | Resource does not exist or is not accessible |
validation_failed |
422 | Request body or parameters failed validation |
conflict |
409 | Conflicting state (e.g., multiple matches on wait with max_results=1) |
idempotency_conflict |
409 | The idempotency key was already used with a different request body |
idempotency_in_progress |
409 | Another request with the same idempotency key is still running |
message_cleanup_in_progress |
409 | A prior submission with the same identity is still being cleaned up; retry after Retry-After |
idempotency_unavailable |
503 | Idempotency was requested but its reservation dependency is unavailable; retry after Retry-After |
rate_limited |
429 | Rate limit exceeded |
server_error |
500 | Internal server error |
Observability Headers
Responses that reach the API observability middleware include:
| Header | Description | Example |
|---|---|---|
X-Request-Id |
Unique identifier for the request | 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d |
X-Processing-Time-Ms |
Server-side processing time in milliseconds | 42 |
Include X-Request-Id in support tickets for faster debugging.
Early authentication, plan, and throttling failures may not include
these headers.
Rate Limiting
Plan limits are enforced in fixed one-minute windows and are shared by all mailboxes in the same organization.
Response Headers:
| Header | Description |
|---|---|
X-RateLimit-Limit |
Maximum requests per minute |
X-RateLimit-Remaining |
Remaining requests in the current window |
Retry-After |
Seconds until retry (429 rate limits, retryable idempotency, or retryable cleanup responses) |
When rate-limited, the API returns 429 Too Many Requests.
GET /rate-limit passes through the same limiter and counts as one request.
Idempotency
For POST /messages and DELETE operations, you can include an
X-Idempotency-Key header to ensure the operation is only processed once, even
if retried:
X-Idempotency-Key: my-unique-key-12345
For POST /messages, use a UUID that is unique to one logical submission and
send the identical body on every retry. Sendpit binds the key and request
fingerprint to the stored Mongo message, so a completed submission can still be
replayed if the short-lived Redis completion record is lost. Reusing the key
with a different body returns 409 idempotency_conflict.
If Sendpit cannot reserve an idempotency key before storing anything, it returns
503 idempotency_unavailable with Retry-After: 5. No message is accepted in
that case; retry the identical request later instead of removing the header.
If a prior submission with the same identity is still being cleaned up, Sendpit
returns 409 message_cleanup_in_progress with Retry-After: 5. Retry the
identical request after that interval.
Cursor Pagination
List endpoints use cursor-based pagination. Pass after, before, and limit query parameters.
Paginated Response Envelope:
{
"data": [...],
"meta": {
"next_cursor": "eyJjIjoiMjAyNi0wMy0xNVQxNDozMjoxNy4wMDArMDA6MDAiLCJpZCI6IjY1YTFiMmMzZDRlNWY2Nzg5MGFiY2RlZiJ9",
"has_more": true,
"limit": 25,
"count": 25
}
}
| Parameter | Type | Default | Description |
|---|---|---|---|
after |
string | - | Cursor to fetch the next page (from meta.next_cursor) |
before |
string | - | Cursor to fetch the previous page |
limit |
integer | 25 | Number of items per page (1-100) |
Endpoint Reference
Every endpoint, with its parameters, request bodies, response schemas and status codes, is published as an OpenAPI document generated from the API source itself, so it cannot drift from the running service:
- Interactive reference: /api/documentation
- OpenAPI document: /api/docs
The interactive reference authorizes with the same mailbox SMTP username and password described under Authentication & API Access, so you can send real requests against your own mailbox from the browser.
The conventions above (error codes, rate limiting, idempotency and cursor pagination) apply to every endpoint and are not repeated per operation.
8. Browser Inbox Live Refresh (WebSocket)
Sendpit uses the Pusher protocol through Laravel Reverb to refresh an open mailbox page when messages arrive or are removed. This is a session-authenticated browser feature for every plan, not a public API WebSocket interface.
Channel
The inbox UI receives events on a private channel bound to the mailbox:
private-mailbox.{id}
This is a session-authenticated browser channel, not a public API WebSocket contract.
Events
| Event Name | Trigger | Payload |
|---|---|---|
message.received |
New email arrives | {id, type, mailbox_id, occurred_at, data: {message fields}} |
message.deleted |
Single message deleted | {id, type, mailbox_id, occurred_at, data: {id}} |
messages.cleared |
All messages cleared | {id, type, mailbox_id, occurred_at, data: {count}} |
Browser Authentication Flow
- Client connects to the WebSocket server:
wss://sendpit.com/app/{APP_KEY} - Client subscribes to the mailbox's private channel
- The server sends an auth challenge
- The signed-in browser POSTs to
/broadcasting/authwith its session cookie, CSRF context, channel name, and socket ID - The server verifies mailbox access and returns a signed channel token
- Client completes the subscription
Browser Clients (Laravel Echo)
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Echo = new Echo({
broadcaster: 'reverb',
key: 'your-reverb-app-key',
wsHost: 'sendpit.com',
wsPort: 443,
wssPort: 443,
forceTLS: true,
enabledTransports: ['ws', 'wss'],
authEndpoint: '/broadcasting/auth',
});
window.Echo.private(`mailbox.${mailboxId}`)
.listen('.message.received', (event) => {
console.log('New message:', event);
})
.listen('.message.deleted', (event) => {
console.log('Message deleted:', event);
})
.listen('.messages.cleared', (event) => {
console.log('Messages cleared:', event);
});
API and CI Clients
API credentials cannot authenticate to this browser channel, which is session-authenticated. API and CI clients should use the Wait API for asynchronous message arrival, or customer webhooks where the selected plan supports them.
9. Plans & Limits
Feature Availability by Plan
| Feature | Free | Basic | Pro | Max |
|---|---|---|---|---|
| Mailboxes | 1 | 3 | Unlimited | Unlimited |
| Teammates | 3 | 5 | Unlimited | Unlimited |
| Stored emails (org-wide) | 200 | 1,000 | 2,500 | 15,000 |
| Retention | 7 days | 14 days | 60 days | 60 days |
| REST API | No | No | 120 req/min | 300 req/min |
| Webhooks | No | No | 5 per mailbox | 20 per mailbox |
| Smart Rules | No | No | 100 rules | 500 rules |
| Auto-labeling | No | No | Yes | Yes |
| Scenario classification | No | No | Yes | Yes |
| Spam score analysis | Score only | Score + rules | Score + rules | Score + rules |
| OTP & link extraction | Yes | Yes | Yes | Yes |
| Enhanced header inspector | No | No | Yes | Yes |
| Link check | No | Yes | Yes | Yes |
| Share links | No | No | Yes | Yes |
| Email forwarding | No | No | 20/hr | 20/hr |
| Browser inbox live refresh | Yes | Yes | Yes | Yes |
| Wait API | No | No | 5 concurrent/mailbox | 10 concurrent/mailbox |
| SMTP transaction logs | No | No | Yes | Yes |
| Email labels | Yes | Yes | Yes | Yes |
| HTML preview | Yes | Yes | Yes | Yes |
| Attachment viewing | Yes | Yes | Yes | Yes |
| Search & filter | Yes | Yes | Yes | Yes |
| TLS encryption | Yes | Yes | Yes | Yes |
Behavior on Downgrade
When downgrading to a lower plan:
- Excess mailboxes: Disabled automatically, least recently used first, until the enabled count fits the new plan's limit; disabled mailboxes reject new emails. Re-enable manually up to the limit (disable another mailbox first when at the limit). Upgrading never re-enables automatically.
- Excess team members: Retain access but no new invitations allowed
- Webhooks: Disabled if new plan doesn't support them; existing configs are preserved but inactive
- API access: SMTP credentials keep working for SMTP, but API requests return
403if API access is removed - Emails over retention: Purged according to new retention period
Limit Enforcement
The mailbox limit counts ENABLED mailboxes: a disabled mailbox holds no capacity.
Limits are checked at:
- Mailbox creation time (in the app this is a redirect with an explanation; REST API limit failures return
403with codeplan_limit) - Mailbox enable time (the toggle explains when you must disable another mailbox first)
- User invitation time
- Email ingestion time
- Hourly, by a reconciliation job that disables enabled mailboxes above the plan limit
- Webhook creation time
- API request time (rate limits)
Attempting to exceed limits returns a 403 error with the plan_limit error code.
The email limit is a stored-email capacity: the number of captured messages your organization currently holds across all its mailboxes, from SMTP and API submissions alike. New messages are rejected while you are at capacity (SMTP with a permanent 5.2.2 response, the API with plan_limit); deleting messages or retention cleanup frees space immediately, and billing renewals do not reset the count. Simultaneous submissions at the final available slot can briefly exceed the limit because enforcement uses count-then-insert; the hourly cleanup keeps the newest messages and removes the oldest excess.
10. Error Handling & Status Codes
Common Errors
| Scenario | HTTP Status | Error Code | Message |
|---|---|---|---|
| Missing or invalid credentials | 401 | unauthenticated |
Check the response message |
| Too many failed sign-ins | 429 | rate_limited |
Retry after the Retry-After window |
| Mailbox or organization disabled | 403 | mailbox_disabled |
The inbox is not accepting requests |
| API not available on plan | 403 | plan_required |
Your plan does not include API access |
| Mailbox limit reached | 403 | plan_limit |
Maximum mailboxes for your plan |
| Webhook limit reached | 403 | plan_limit |
Maximum webhooks per mailbox |
| Stored-email capacity reached | 403 | plan_limit |
Organization is at its stored-email capacity; delete stored emails or upgrade (idempotent retries of an already-stored message still replay their original 201) |
| Message not found | 404 | not_found |
Message not found |
| Invalid request body | 422 | validation_failed |
Validation details in error.details |
| Rate limit exceeded | 429 | rate_limited |
Too many requests |
| Invalid SMTP credentials | N/A | - | SMTP authentication failed |
Developer Recommendations
- Check plan limits before operations: Use
GET /rate-limitto check current rate limit status - Handle 403 gracefully: Display upgrade prompts or notify administrators
- Implement retry logic for transient failures: Use exponential backoff for 429 and 5xx responses
- Log error responses: Include
X-Request-Idfor debugging with support - Use cursor pagination: Prefer
after/limitover offset pagination for reliable iteration
11. Security Model
Tenant Isolation
- Organizations are fully isolated from each other
- Mailbox data is scoped to the owning organization
- API credentials are scoped to a single mailbox
- Team members only see resources they have explicit access to
Attachment Access Control
Attachments are protected by:
- Authentication requirement (valid mailbox credentials or logged-in user)
- Mailbox access verification (the credentials must belong to the mailbox)
- Signed URLs with 1-hour expiration for downloads
Attachments are not publicly accessible.
Webhook Signing Guarantees
- Secrets are generated using cryptographically secure random bytes (256-bit)
- Secrets are encrypted at rest using AES-256
- Signatures use HMAC-SHA256
- Timestamp validation prevents replay attacks
- Secrets can be regenerated at any time (invalidates old signatures)
12. CI / Automation Examples
GitHub Actions with REST API
name: Email Integration Tests
on:
push:
branches: [main, develop]
jobs:
email-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Send test email via SMTP
run: |
# Your application sends an email here
npm run send-welcome-email -- --to test@staging.example.com
- name: Wait for email via API
run: |
RESPONSE=$(curl -s -X POST "https://sendpit.com/api/v1/messages/wait" \
-u "${{ secrets.SENDPIT_SMTP_USERNAME }}:${{ secrets.SENDPIT_SMTP_PASSWORD }}" \
-H "Content-Type: application/json" \
-d '{
"filters": {
"to": "test@staging.example.com",
"subject": "Welcome"
},
"timeout": 30,
"max_results": 1
}')
echo "$RESPONSE" | jq .
# Verify the email arrived
SUBJECT=$(echo "$RESPONSE" | jq -r '.data[0].subject')
if [ "$SUBJECT" != "Welcome to Our Platform" ]; then
echo "Unexpected subject: $SUBJECT"
exit 1
fi
- name: Extract OTP code
run: |
MESSAGE_ID=$(echo "$RESPONSE" | jq -r '.data[0].id')
EXTRACTIONS=$(curl -s "https://sendpit.com/api/v1/messages/$MESSAGE_ID/extractions" \
-u "${{ secrets.SENDPIT_SMTP_USERNAME }}:${{ secrets.SENDPIT_SMTP_PASSWORD }}")
OTP=$(echo "$EXTRACTIONS" | jq -r '.data.codes[0]')
echo "Extracted OTP: $OTP"
Webhook-Driven Test Workflow
# test_webhook_handler.py
import time
from flask import Flask, request
import threading
app = Flask(__name__)
received_emails = []
email_event = threading.Event()
@app.route('/webhook', methods=['POST'])
def webhook():
payload = request.get_json()
received_emails.append(payload)
email_event.set()
return '', 200
def wait_for_email(timeout=30):
"""Block until an email is received or timeout."""
email_event.clear()
if email_event.wait(timeout):
return received_emails[-1]
raise TimeoutError("No email received within timeout")
# In your test
def test_welcome_email():
register_user("test@example.com")
email = wait_for_email(timeout=10)
assert email['data']['subject'] == "Welcome to Our Platform"
assert email['data']['to'][0]['address'] == "test@example.com"
Local Development with ngrok
Step 1: Start ngrok
ngrok http 3000
Step 2: Configure webhook in Sendpit
Use the ngrok HTTPS URL as your webhook endpoint:
curl -s -X POST "https://sendpit.com/api/v1/webhooks" \
-u "mb_a1b2c3d4e5f6:your-smtp-password" \
-H "Content-Type: application/json" \
-d '{
"endpoint_url": "https://a1b2c3d4.ngrok-free.app/webhooks/sendpit",
"description": "Local dev webhook"
}'
13. Versioning & Changelog Policy
API Versioning
The API is versioned via the URL path (/api/v1). The current version is v1.
- Additive changes (new fields, new endpoints): Deployed without notice. Your code should ignore unknown fields.
- Breaking changes (removed fields, type changes, removed endpoints): Announced 30 days in advance via email to organization owners. A new API version will be introduced.
Webhook Payload Versioning
Webhook payloads support version 1 and 2. When creating or updating a webhook, specify the payload_version field:
- Version 1: Supports
message.receivedevents only. - Version 2: Supports all event types (
message.received,message.deleted,messages.cleared).
Backward Compatibility
We commit to:
- Not removing existing response fields without notice
- Not changing field types without notice
- Maintaining signature algorithm compatibility
- Supporting deprecated pagination (offset-based) alongside cursor pagination
14. FAQ
Can I retrieve email content via API?
Yes. Use GET /messages/{id} for the full message including bodies and headers. Add ?include=attachments to include attachment metadata, or use the inspection endpoints for focused access to raw email, headers, bodies, attachments, and extractions.
How do I wait for an email in my CI pipeline?
Use POST /messages/wait with your desired filters and a timeout. The endpoint long-polls until a matching email arrives or the timeout expires. Wait API plan availability: Pro and above. See the endpoint reference.
Why doesn't the webhook payload include the email body?
For security and performance reasons. Email bodies can be large and may contain sensitive data. The webhook notifies you of receipt; use the REST API to retrieve full content.
What happens if my webhook endpoint is down?
Sendpit retries delivery up to 5 times over approximately 81 minutes. If all attempts fail, the webhook is logged as failed. After 10 consecutive failures across any deliveries, the webhook is auto-disabled.
Do I need to create an API token?
No. The API authenticates with the mailbox's own SMTP username and password over HTTP Basic, so the credentials you already use to send mail are the credentials you use to read it back. Find them under Settings > SMTP credentials for the mailbox.
Are emails permanently stored?
No. Emails are retained according to your plan's retention period (7-60 days), then automatically deleted.
Can I use custom domains?
SMTP credentials work regardless of the sender/recipient domain in emails. The routing is based on SMTP authentication, not email addresses.
What email size limits apply?
SMTP submissions are limited to 25 MiB including attachments. HTTP submissions use separate decoded attachment limits, which are enforced through validation.
15. Support & Feedback
Reporting Issues
For bugs, unexpected behavior, or security concerns:
- Email: support@sendpit.com
- Include: Organization ID, mailbox name (not credentials),
X-Request-Idheader value, timestamp, and reproduction steps
Feature Requests
Submit feature requests through the in-app feedback form or email. We prioritize based on impact on existing workflows, alignment with product direction, and technical feasibility.
Security Vulnerabilities
Report security issues to security@sendpit.com. We follow responsible disclosure practices and will acknowledge receipt within 48 hours.
Last updated: March 2026