Sending API
Send order confirmations, receipts and form replies from your own systems. Emails are signed with your domain and sent from the same servers as your regular email at Alweo.
Getting started
- The domain must have email at Alweo, with SPF, DKIM and DMARC in place. The health check in the panel shows whether everything is correct.
- An administrator creates a key in the Alweo panel under Email → Sending API. The key is tied to one domain and is shown only once, so save it right away.
- Add the key to your system, preferably as an environment variable and never in the code.
- Send a first email using the example below.
The base URL is https://alweo.se/api/v1. All requests and responses are JSON in UTF-8.
The whole API is also available as an OpenAPI file (3.1) that you can import into Postman, Insomnia or a code generator.
Authentication
Send the key in the Authorization header:
Authorization: Bearer alw_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Keys start with alw_. We store only a check value of the key, not the key itself, so it cannot be shown again. If you lose a key, delete it in the panel and create a new one.
Test keys
While you build and try out the integration, you can use a test key. Create it in the same place in the panel by ticking Test key. Test keys start with alw_test_.
- Requests are validated exactly as usual, with the same fields, limits and error codes.
- No emails are sent. Each recipient is immediately marked as
delivered, with a note that nothing was sent. - Webhooks are sent as usual, with
"test": trueindata. - The response contains
"test": true, and test sends do not count toward the monthly quota.
{ "id": "6f1c…", "test": true }
Switch to a regular key once the integration works.
Send an email
| Field | Type | Description |
|---|---|---|
from required | string | Sender, either order@yourcompany.se or Web shop <order@yourcompany.se>. The address must be on the key's domain. |
to required | string or list | One or more recipients. |
cc optional | string or list | Carbon copy. |
bcc optional | string or list | Blind carbon copy. |
subject required | string | Subject line, at most 900 characters. |
html html or text | string | The email as HTML. |
text html or text | string | The email as plain text. Sending both html and text is recommended; it improves deliverability. |
reply_to optional | string | Address that replies should go to. |
headers optional | object | Custom headers, for example {"X-Order": "1234"}. |
attachments optional | list | Attachments, see Attachments. |
At most 50 recipients in total across to, cc and bcc. To send the same email to many people, make one request per recipient so that no one sees anyone else's address.
curl https://alweo.se/api/v1/emails \
-H "Authorization: Bearer $ALWEO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Web shop <order@yourcompany.se>",
"to": ["customer@example.com"],
"subject": "Thanks for your order",
"html": "<p>We have received your order.</p>",
"text": "We have received your order."
}'const response = await fetch("https://alweo.se/api/v1/emails", {
method: "POST",
headers: {
"Authorization": "Bearer " + process.env.ALWEO_API_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify({
from: "Web shop <order@yourcompany.se>",
to: ["customer@example.com"],
subject: "Thanks for your order",
html: "<p>We have received your order.</p>",
text: "We have received your order."
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.name + ": " + data.message);
console.log(data.id);$ch = curl_init("https://alweo.se/api/v1/emails");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("ALWEO_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"from" => "Web shop <order@yourcompany.se>",
"to" => ["customer@example.com"],
"subject" => "Thanks for your order",
"html" => "<p>We have received your order.</p>",
"text" => "We have received your order.",
]),
]);
$data = json_decode(curl_exec($ch), true);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($code !== 200) throw new Exception($data["name"] . ": " . $data["message"]);
echo $data["id"];import os, requests
response = requests.post(
"https://alweo.se/api/v1/emails",
headers={"Authorization": "Bearer " + os.environ["ALWEO_API_KEY"]},
json={
"from": "Web shop <order@yourcompany.se>",
"to": ["customer@example.com"],
"subject": "Thanks for your order",
"html": "<p>We have received your order.</p>",
"text": "We have received your order.",
},
timeout=60,
)
data = response.json()
if not response.ok:
raise RuntimeError(f'{data["name"]}: {data["message"]}')
print(data["id"])Response
{ "id": "6f1c2a7e-3b4d-4f5a-9c8e-1d2b3c4d5e6f" }
Save the id if you want to track delivery. If any recipient was on the suppression list, it was removed from the send, and the response lists which ones:
{ "id": "6f1c…", "suppressed": ["old@example.com"] }
A 200 response means our mail server has accepted the email for delivery. Whether it then arrives is shown under Get status or via webhooks.
Attachments
Each attachment is an object with the file content encoded in base64:
| Field | Description |
|---|---|
filename | The file name the recipient sees, for example invoice-1234.pdf. |
content | The file content in base64. |
content_type optional | For example application/pdf. |
"attachments": [
{ "filename": "invoice-1234.pdf", "content": "JVBERi0xLjQK…", "content_type": "application/pdf" }
]
At most 20 attachments and 10 MB in total per email, measured on the file sizes before base64 encoding. Larger files are better sent as a link.
Get status
Returns an email sent with the same key, and the delivery result for each recipient.
{
"id": "6f1c…",
"from": "order@yourcompany.se",
"recipients": 1,
"subject": "Thanks for your order",
"status": "sent",
"error": null,
"created_at": "2026-10-09T05:41:12.000Z",
"delivery": {
"customer@example.com": {
"status": "delivered",
"detail": "250 2.0.0 OK …",
"at": "2026-10-09T05:41:14.000Z"
}
}
}
| Value | Meaning |
|---|---|
status: "sent" | Our server accepted the email for delivery. |
status: "failed" | The email could not be sent; the reason is in error. |
delivered | The recipient's server accepted the email. |
delivery_delayed | The recipient's server responded with a temporary error. We retry automatically. |
bounced | The email could not be delivered. detail shows the response from the recipient's server. |
Delivery status is updated about every two minutes, so delivery is empty right after sending. Status is tracked for three days. Sometimes the recipient's server first accepts the email and sends a bounce later. That bounce arrives as a regular email to the sender address and is not shown here.
Webhooks
With a webhook, we send an event to your server as soon as something happens, instead of you polling for status. An administrator adds the URL in the panel under Email → Sending API → Webhooks. The URL must start with https://, and a company can have at most five webhooks. When the webhook is created, a secret starting with whsec_ is shown. It is shown only once.
Events
| Type | Sent when | Contents of data |
|---|---|---|
email.sent | Our server has accepted the email. | email_id, from, to (list), subject |
email.delivered | The recipient's server accepted the email. | email_id, to, detail |
email.delivery_delayed | Temporary error; we are retrying. | email_id, to, detail |
email.bounced | The email could not be delivered. | email_id, to, detail |
email.test | You click Send test event in the panel. | message, webhook_id |
email.suppressed | The address has been added to the suppression list after a permanent bounce. | email_id, to, reason |
Each event is a POST with JSON. For emails with several recipients, there is one event per recipient, except for email.sent.
POST https://your-server.com/alweo-webhook
Content-Type: application/json
User-Agent: Alweo-Webhooks/1
Alweo-Signature: t=1760000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
"type": "email.delivered",
"created_at": "2026-10-09T05:41:14.000Z",
"data": {
"email_id": "6f1c…",
"to": "customer@example.com",
"detail": "250 2.0.0 OK …"
}
}
Send a test event
Each webhook in the panel has a Send test event button. It immediately sends a signed event of type email.test and shows what your server responded and how long it took. This confirms that the URL is reachable and lets you check that the signature is accepted.
{ "type": "email.test", "created_at": "…", "data": { "message": "Test event from Alweo. Respond with 2xx.", "webhook_id": "12" } }
Responses and retries
Respond with a 2xx code within 10 seconds. Any other response, or no response at all, counts as a failure. We then retry after 1, 5, 15, 60 and 180 minutes, so at most six attempts. The latest response is shown with the webhook in the panel.
In rare cases the same event can arrive twice, and the order is not guaranteed. Use email_id, to and type to recognize an event you have already handled.
Verify the signature
The Alweo-Signature header shows that the event comes from us and has not been modified in transit. It has the form t=<time>,v1=<signature>, where:
tis the time the event was sent, in seconds since 1970 (Unix time).v1is the HMAC-SHA256 in hex, with the webhook's secret as the key, of the textt + "." + raw body.
Compute the same value and compare. Use the raw body exactly as it arrived, not JSON that has been parsed and re-encoded. We recommend rejecting events where t is more than five minutes old.
const crypto = require("crypto");
const express = require("express");
const app = express();
app.post("/alweo-webhook", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("Alweo-Signature") || "";
const t = (/t=(\d+)/.exec(header) || [])[1];
const v1 = (/v1=([0-9a-f]{64})/.exec(header) || [])[1];
if (!t || !v1 || Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(400);
const expected = crypto.createHmac("sha256", process.env.ALWEO_WEBHOOK_SECRET)
.update(t + "." + req.body.toString("utf8")).digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) return res.sendStatus(401);
const event = JSON.parse(req.body);
console.log(event.type, event.data);
res.sendStatus(200);
});$body = file_get_contents("php://input");
$header = $_SERVER["HTTP_ALWEO_SIGNATURE"] ?? "";
preg_match('/t=(\d+)/', $header, $t);
preg_match('/v1=([0-9a-f]{64})/', $header, $v1);
if (!$t || !$v1 || abs(time() - (int) $t[1]) > 300) { http_response_code(400); exit; }
$expected = hash_hmac("sha256", $t[1] . "." . $body, getenv("ALWEO_WEBHOOK_SECRET"));
if (!hash_equals($expected, $v1[1])) { http_response_code(401); exit; }
$event = json_decode($body, true);
// $event["type"], $event["data"]
http_response_code(200);import hmac, hashlib, os, re, time
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/alweo-webhook")
def alweo_webhook():
body = request.get_data()
header = request.headers.get("Alweo-Signature", "")
t = re.search(r"t=(\d+)", header)
v1 = re.search(r"v1=([0-9a-f]{64})", header)
if not t or not v1 or abs(time.time() - int(t.group(1))) > 300:
abort(400)
expected = hmac.new(os.environ["ALWEO_WEBHOOK_SECRET"].encode(),
t.group(1).encode() + b"." + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1.group(1)):
abort(401)
event = request.get_json()
print(event["type"], event["data"])
return "", 200Suppression list
When an email bounces permanently, for example because the address does not exist, the address is added to your company's suppression list. This protects your domain's reputation, since repeated sends to dead addresses make other mail servers distrust the sender.
- Suppressed addresses are removed from future sends and listed in the response's
suppressedfield. - If all recipients are suppressed, the API responds with
422 recipients_suppressed. - Temporary errors, such as a full mailbox or a server that does not respond, do not suppress the address.
- If the recipient has corrected their address, an administrator removes it from the suppression list in the panel.
Limits and plans
| Limit | Value |
|---|---|
| Recipients per email | 1–50, counting to, cc and bcc together |
| Attachments | At most 20 files and 10 MB in total |
| Entire request | At most 15 MB of JSON |
| Per key and day | 1,000 emails, counted over the 24 hours before the request |
| Per month and company | Depends on the plan, counted in recipients per calendar month (Swedish time) |
| Plan | Recipients per month | Price |
|---|---|---|
| Free | 10 | 0 SEK for one month. The trial starts when the first key is created. |
| Start | 10,000 | 1,788 SEK/yr excl. VAT |
| Pro | 50,000 | 3,588 SEK/yr excl. VAT |
The plan is selected in the panel under Email → Sending API. If you need more than the daily limit or the Pro plan, email hello@alweo.se. We review your sending before the limit is raised.
Error codes
Errors always respond with JSON in the same form:
{ "statusCode": 403, "name": "invalid_from", "message": "The key may only send from @yourcompany.se" }
| Code | name | Meaning |
|---|---|---|
| 401 | unauthorized | The key is missing, misspelled or has been deleted. |
| 402 | trial_ended | The trial month has ended. Choose Start or Pro in the panel. |
| 403 | invalid_from | The sender is not on the key's domain. |
| 404 | not_found | The email does not exist, or was sent with a different key. |
| 422 | validation_error | A field is missing or invalid. message says which one. |
| 422 | recipients_suppressed | All recipients are on the suppression list. |
| 429 | rate_limit_exceeded | The key's daily limit has been reached. |
| 429 | monthly_quota_exceeded | The monthly quota for the plan has been reached. |
| 500 | internal_error | Something went wrong on our side. Try again. |
| 502 | send_failed | The mail server could not accept the email. Try again shortly. |
| 503 | not_configured | Sending is temporarily disabled. |
4xx errors do not go away by retrying; the request must be corrected. On 500, 502 and 503 you can retry after a while, preferably with increasing wait times. Ongoing incidents are shown on the status page.
Tips for good deliverability
- Send both
htmlandtext. - Use a sender address that can receive replies, or set
reply_to. - Only send to people who expect the email, such as customers who placed an order or filled in a form. Newsletters to large lists are best handled with a dedicated newsletter tool.
- Remove bouncing addresses from your own system. Listen for
email.bouncedandemail.suppressed. - Use one key per system, for example one for the web shop and one for the ERP system. Then you can replace or delete one without affecting the other.
Questions about the API: hello@alweo.se