On this page
API Docs
Email API
Transactional email on the same account, key and balance as your SMS, with data stored in Canada and CASL rules built in.
Quickstart
With a test key you can send before setting up a domain: send from onboarding@test.honkio.ca to one of the test addresses below. Nothing is delivered and nothing is charged.
curl -X POST https://api.honkio.ca/v1/emails \
-H "Authorization: Bearer mk_test_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <onboarding@test.honkio.ca>",
"to": "delivered@test.honkio.ca",
"subject": "Hello from HonkIO",
"html": "<p>It works.</p>"
}'
# Response 201: { "id": "cm...", "status": "queued", "scheduled_at": null }The same send with the Node SDK, @honkio/node. Every call resolves to data or error and never throws for an API error.
import { Honkio } from '@honkio/node'
const honkio = new Honkio(process.env.HONKIO_API_KEY)
const { data, error } = await honkio.emails.send({
from: 'Acme <onboarding@test.honkio.ca>',
to: 'delivered@test.honkio.ca',
subject: 'Hello from HonkIO',
html: '<p>It works.</p>',
})
if (error) console.error(error.name, error.message)
else console.log(data.id)Or with plain fetch:
const res = await fetch('https://api.honkio.ca/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.HONKIO_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'Acme <onboarding@test.honkio.ca>',
to: 'delivered@test.honkio.ca',
subject: 'Hello from HonkIO',
html: '<p>It works.</p>',
}),
})
console.log(res.status, await res.json())Then add your own domain, switch to a live key, and send from an address on that domain.
Sending domains
Add a domain or subdomain you control, then publish the records the API returns at your DNS host. Nothing goes on your root domain, so your own mail and SPF record are untouched.
| Record | Type | Name (example) | Purpose |
|---|---|---|---|
| DKIM | TXT | honkio1._domainkey.mail.acme.ca | Signs your mail. Required. |
| MX | MX | send.mail.acme.ca | Return path for bounces, on the send. subdomain. Required. |
| SPF | TXT | send.mail.acme.ca | Authorizes the return path, on the send. subdomain. Required. |
| DMARC | TXT | _dmarc.mail.acme.ca | Recommended, not checked. Start with p=none. |
curl -X POST https://api.honkio.ca/v1/email-domains \
-H "Authorization: Bearer mk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "domain": "mail.acme.ca" }'
# Publish the returned records at your DNS host, then check them:
curl -X POST https://api.honkio.ca/v1/email-domains/DOMAIN_ID/verify \
-H "Authorization: Bearer mk_live_YOUR_KEY"
# → { "status": "verified", "verified": true, "missing": [] }
# Tracking per domain (null = the platform default):
curl -X PATCH https://api.honkio.ca/v1/email-domains/DOMAIN_ID \
-H "Authorization: Bearer mk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "open_tracking": false, "click_tracking": null }'A domain is pending until its records are found, verifying while they propagate, and verified once DKIM, the MX, SPF and the email provider all agree. It moves to failed once its records are still missing after 7 days or the email provider rejects the domain outright, and to temporarily failed on a transient provider failure or when an already-verified domain fails a re-check; call verify again once the records are fixed. Verification is checked again every day; a record that disappears gives the domain 72 hours to recover before it stops sending.
Call verify any time to check now. The response names any record that is still missing.
A domain belongs to whoever proves they control its DNS. Another account's verified domain can't be added; an unverified claim blocks others for 72 hours, and the 409 EMAIL_DOMAIN_IN_USE answer says when it lapses.
Open and click tracking are off by default. Turn them on per domain with PATCH, or per email with the tracking field; null restores the platform default.
An API key can be restricted to specific sending domains when it is created. A restricted key can send only from its own domains, and can only reschedule or cancel an email that was sent from one of them; adding, verifying and managing domains themselves is unaffected and stays open to any key with the email_domains permission.
Sending email
POST /v1/emails sends one email. Field names match Resend's, so most existing requests work unchanged.
| Field | Meaning |
|---|---|
| from | Sender, optionally with a name. Its domain must be a verified sending domain on your account. |
| to, cc, bcc | A string or an array; at most 50 recipients across to, cc and bcc. |
| reply_to | A string or an array. |
| subject, html, text | Subject up to 998 characters, and at least one of html or text. Omit all three when you send a template. |
| variables | Values to substitute into placeholders in subject, html and text. Ignored when template is set; a template's own variables go in template.variables instead. |
| template | Send a stored template by id or alias, with variables. |
| attachments | filename plus one of content (base64), content_base64 or path (an HTTPS URL). content_id embeds the file inline. 10 MB in total. |
| tags | Up to 10 name and value pairs, each part 1 to 256 letters, digits, underscores or hyphens. Filter with GET /v1/emails?tag=name:value. Tags are kept for as long as the email record, after the subject and body are purged, so keep personal information out of them. A fetched email's body_purged_at is null until that happens. |
| headers | Extra email headers, sent as given. |
| scheduled_at | An ISO 8601 date-time, 1 minute to 30 days ahead. |
| is_commercial | False by default. True marks a commercial message under CASL; see below. |
| tracking | Per email opens and clicks, overriding the domain's setting. |
curl -X POST https://api.honkio.ca/v1/emails \
-H "Authorization: Bearer mk_live_YOUR_KEY" \
-H "Idempotency-Key: order-1042-receipt" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme Receipts <receipts@mail.acme.ca>",
"to": ["ada@example.com"],
"reply_to": "help@acme.ca",
"subject": "Your receipt for order 1042",
"html": "<img src=\"cid:logo\"><p>Thanks for your order.</p>",
"text": "Thanks for your order.",
"tags": [{ "name": "kind", "value": "receipt" }],
"attachments": [
{ "filename": "receipt.pdf", "path": "https://files.acme.ca/r/1042.pdf" },
{ "filename": "logo.png", "content": "iVBORw0KGgo...", "content_id": "logo" }
],
"scheduled_at": "2026-10-01T13:00:00-04:00"
}'
# Move it, or cancel it (refunded), while it is still scheduled:
curl -X PATCH https://api.honkio.ca/v1/emails/EMAIL_ID -H "Authorization: Bearer mk_live_YOUR_KEY" \
-H "Content-Type: application/json" -d '{ "scheduled_at": "2026-10-02T09:00:00-04:00" }'
curl -X DELETE https://api.honkio.ca/v1/emails/EMAIL_ID -H "Authorization: Bearer mk_live_YOUR_KEY"A path attachment is fetched once when you send, over HTTPS only, from a public address, within 10 seconds. If it can't be fetched, the send is refused with EMAIL_ATTACHMENT_FETCH_FAILED and nothing is charged. Attachment bytes are stored only until the email is sent.
Scheduled emails can be moved with PATCH or cancelled with DELETE while they are still scheduled; cancelling refunds the charge. Attachments work on scheduled sends too.
Send an Idempotency-Key header on anything that may retry. The same key returns the original response instead of sending, and charging, twice.
A replay that arrives while the first request with that key is still being charged gets 409 EMAIL_IDEMPOTENCY_IN_PROGRESS. Retry the same key shortly rather than switching to a new one.
Batch sending
Send a JSON array of up to 100 independent emails. Every item is checked before any is sent; an invalid item fails the whole call, while a compliance refusal such as a suppressed address rejects only that item and is listed with its index.
curl -X POST https://api.honkio.ca/v1/emails/batch \
-H "Authorization: Bearer mk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '[
{ "from": "noreply@mail.acme.ca", "to": "ada@example.com", "subject": "Your code", "text": "123456" },
{ "from": "noreply@mail.acme.ca", "to": "lin@example.com", "template": { "id": "welcome", "variables": { "first_name": "Lin" } } }
]'
# → 200 { "data": [{ "id": "cm..." }, { "id": "cm..." }], "batch_id": "...", "rejected": [] }data lists only the emails that were actually sent, so it is not aligned with your request by position. Match a rejection back to its input item with rejected's index field.
Or send one message to up to 500 recipients as an object with a recipients list, each recipient with its own variables. Attachments and per item scheduling are not available in batches.
curl -X POST https://api.honkio.ca/v1/emails/batch \
-H "Authorization: Bearer mk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "noreply@mail.acme.ca",
"template": { "id": "shipping-update" },
"recipients": [
{ "to": "ada@example.com", "variables": { "first_name": "Ada", "tracking": "1Z999" } },
{ "to": "lin@example.com", "variables": { "first_name": "Lin", "tracking": "1Z998" } }
]
}'Templates
Templates use double braces around a variable name of letters, digits and underscores. Triple braces are accepted too and are treated the same: values are always HTML escaped in html. There are no conditionals or loops.
Subject: Your order has shipped, {{ first_name }}
<p>Hi {{ first_name }}, track it with {{ tracking }}.</p>
"variables": [
{ "key": "first_name", "fallback": "there" },
{ "key": "tracking" }
]Every variable a template uses must be declared, optionally with a fallback. A send that leaves a variable with neither a value nor a fallback is refused and lists the missing keys.
Edits save to a draft. Publishing makes the draft the next numbered version, which sends then use; rolling back publishes an old version again as a new one. Scheduled emails keep the version they were sent with. Send by id or alias, and a from or reply_to in the request overrides the template's.
# Create a draft, publish it as version 1, send it by alias
curl -X POST https://api.honkio.ca/v1/email-templates -H "Authorization: Bearer mk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Shipping update", "alias": "shipping-update", "subject": "Shipped, {{ first_name }}",
"html": "<p>Track it with {{ tracking }}.</p>",
"variables": [{ "key": "first_name", "fallback": "there" }, { "key": "tracking" }] }'
curl -X POST https://api.honkio.ca/v1/email-templates/shipping-update/publish -H "Authorization: Bearer mk_live_YOUR_KEY"
curl -X POST https://api.honkio.ca/v1/emails -H "Authorization: Bearer mk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "from": "noreply@mail.acme.ca", "to": "ada@example.com",
"template": { "id": "shipping-update", "variables": { "tracking": "1Z999" } } }'
# Roll back: copies version 1 into the draft and publishes it as a new version
curl -X POST https://api.honkio.ca/v1/email-templates/shipping-update/rollback -H "Authorization: Bearer mk_live_YOUR_KEY" \
-H "Content-Type: application/json" -d '{ "version": 1 }'Test mode
Test keys send nothing and charge nothing, but run the same checks and fire the same webhooks as a live key. Send from onboarding@test.honkio.ca without any domain, to these addresses:
| Address | What happens |
|---|---|
| delivered@test.honkio.ca | Delivered. |
| bounced@test.honkio.ca | Hard bounce; the address is added to your suppressions. |
| complained@test.honkio.ca | Delivered, then a spam complaint; the address is suppressed and opted out. |
| delayed@test.honkio.ca | A delivery delay, then delivered. |
| *@test.honkio.ca | Any other address, and any ordinary recipient on a test key, is delivered. |
The magic outcome is read from to only: the first to address that matches one of these local parts decides it. cc and bcc are never inspected, so recipients placed there are always delivered.
Suppression works as it does live: a second send to bounced@test.honkio.ca is refused with EMAIL_SUPPRESSED. Add a label, such as bounced+2@test.honkio.ca, for a fresh address. A live key can't send to test.honkio.ca at all; that is refused with EMAIL_TEST_ADDRESS_LIVE_KEY.
Emails and webhook events from a test key carry livemode false.
CASL and commercial email
Email is transactional by default: receipts, password resets, alerts and account notices. Transactional email needs no consent record.
Set is_commercial to true for anything that promotes a product or service. A commercial email goes to exactly one recipient, needs CASL consent on file for that address, and carries an unsubscribe footer and a one click unsubscribe header that mail clients honour. Record email consent the same way as for SMS, with email_address instead of phone_number.
Marketing email must set is_commercial to true. It defaults to false, and a transactional send also reaches people who unsubscribed, so a promotional email sent without the flag would get around their unsubscribe and CASL consent.
curl -X POST https://api.honkio.ca/v1/compliance/consents \
-H "Authorization: Bearer mk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"email_address": "ada@example.com",
"consent_type": "express",
"source_description": "Newsletter checkbox on acme.ca/signup"
}'
# Then a commercial send to that one address:
curl -X POST https://api.honkio.ca/v1/emails -H "Authorization: Bearer mk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "from": "news@mail.acme.ca", "to": "ada@example.com", "subject": "Fall sale",
"html": "<p>20% off this week.</p>", "is_commercial": true }'DELETE /v1/compliance/consents/email/:address revokes that address's active consents (needs compliance:m or compliance:d); it answers 404 NOT_FOUND when none are active. Opt-outs and suppressions are separate and untouched.
This is how the API applies CASL, not legal advice about your own messages.
Suppressions
Hard bounces and spam complaints add an address to your suppression list automatically, and no email is sent to it again. An unsubscribe adds it too, but blocks commercial email only: receipts, password resets and other transactional email still reach the address. An address you block yourself gets no email at all. You can list suppressions, or remove one when the person asks to hear from you again.
curl "https://api.honkio.ca/v1/email-suppressions?reason=hard_bounce" -H "Authorization: Bearer mk_live_YOUR_KEY"
curl -X POST https://api.honkio.ca/v1/email-suppressions -H "Authorization: Bearer mk_live_YOUR_KEY" \
-H "Content-Type: application/json" -d '{ "email_address": "ada@example.com", "reason": "manual" }'
curl -X DELETE https://api.honkio.ca/v1/email-suppressions/ada%40example.com -H "Authorization: Bearer mk_live_YOUR_KEY"Email webhooks
Subscribe to email events the same way as SMS events. Deliveries are signed exactly as described in Verifying signatures.
Every event arrives as one JSON object with the same six top level fields. Only data changes from event to event.
| Field | Meaning |
|---|---|
| id | The event id, a UUID. It is the same on every attempt and every replay of this event, so store it and skip any id you have already processed. |
| type | The event name, e.g. message.received. The same value is sent in the X-HonkIO-Event header. |
| created | When the event happened, ISO 8601 in UTC with milliseconds. A retry or a replay keeps the original value. |
| account_id | The HonkIO account the event belongs to. |
| livemode | true when a live key's action caused the event, false for a test key's simulation. Account and phone number events are always true. |
| data | The event's own fields, listed under each event below. |
email.queued
An email was accepted and charged, and is queued, or scheduled when you passed scheduled_at.
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"type": "email.queued",
"created": "2026-09-24T12:00:00.000Z",
"account_id": "clxxxaccountxxxxxxxxxxxxx",
"livemode": true,
"data": {
"email": {
"id": "clxxxemailxxxxxxxxxxxxxxx",
"from": "receipts@mail.acme.ca",
"to": [
"ada@example.com"
],
"subject": "Your receipt",
"tags": [
{
"name": "category",
"value": "receipt"
}
],
"status": "queued",
"is_commercial": false
}
}
}| data field | Meaning |
|---|---|
| email.id | The HonkIO email id, as returned by POST /v1/emails. |
| email.from | The from address the email was sent with. |
| email.to | The recipient addresses, as a list. |
| email.subject | The subject line. |
| email.tags | The tags you set on the email, as a list of name and value pairs; empty when there are none. |
| email.status | queued, or scheduled when the email waits for scheduled_at. Absent on emails sent as part of a batch. |
| email.batch_id | Emails sent through POST /v1/emails/batch only: the batch the email belongs to. |
| email.is_commercial | Whether you marked the email as commercial (CASL), which decides the unsubscribe footer and header. |
email.sent
The email provider accepted the message for delivery.
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"type": "email.sent",
"created": "2026-09-24T12:00:00.000Z",
"account_id": "clxxxaccountxxxxxxxxxxxxx",
"livemode": true,
"data": {
"email": {
"id": "clxxxemailxxxxxxxxxxxxxxx",
"ses_message_id": "0101019xxxxxxxxx-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-000000",
"from": "receipts@mail.acme.ca",
"to": [
"ada@example.com"
],
"subject": "Your receipt",
"tags": []
}
}
}| data field | Meaning |
|---|---|
| email.id | The HonkIO email id, as returned by POST /v1/emails. |
| email.from | The from address the email was sent with. |
| email.to | The recipient addresses, as a list. |
| email.ses_message_id | The email provider's id for the message, for support requests. Present on the events the provider reports. |
| email.subject | The subject line. |
| email.tags | The tags you set on the email, as a list of name and value pairs; empty when there are none. |
email.delivered
The receiving mail server accepted the message.
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"type": "email.delivered",
"created": "2026-09-24T12:00:00.000Z",
"account_id": "clxxxaccountxxxxxxxxxxxxx",
"livemode": true,
"data": {
"email": {
"id": "clxxxemailxxxxxxxxxxxxxxx",
"to": [
"ada@example.com"
],
"from": "receipts@mail.acme.ca",
"ses_message_id": "0101019xxxxxxxxx-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-000000",
"tags": [
{
"name": "category",
"value": "receipt"
}
]
},
"details": {
"delivered_at": "2026-10-01T17:00:03.512Z",
"smtp_response": "250 2.0.0 OK",
"processing_time_ms": 1204,
"recipients": [
"ada@example.com"
],
"ses_message_id": "0101019xxxxxxxxx-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-000000"
}
}
}| data field | Meaning |
|---|---|
| email.id | The HonkIO email id, as returned by POST /v1/emails. |
| email.to | The recipient addresses, as a list. |
| email.from | The from address the email was sent with. |
| email.ses_message_id | The email provider's id for the message, for support requests. Present on the events the provider reports. |
| email.tags | The tags you set on the email, as a list of name and value pairs; empty when there are none. |
| details.delivered_at | When the receiving server accepted the message, ISO 8601 in UTC. |
| details.smtp_response | The receiving server's SMTP answer, as given. |
| details.processing_time_ms | How long delivery took from acceptance, in milliseconds. |
| details.recipients | The recipient addresses this report covers. |
| details.ses_message_id | The same provider id as email.ses_message_id. |
email.delivery_delayed
The receiving server is deferring the message; delivery is still being retried. Several can arrive for one email.
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"type": "email.delivery_delayed",
"created": "2026-09-24T12:00:00.000Z",
"account_id": "clxxxaccountxxxxxxxxxxxxx",
"livemode": true,
"data": {
"email": {
"id": "clxxxemailxxxxxxxxxxxxxxx",
"to": [
"ada@example.com"
],
"from": "receipts@mail.acme.ca",
"ses_message_id": "0101019xxxxxxxxx-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-000000",
"tags": [
{
"name": "category",
"value": "receipt"
}
]
},
"details": {
"delay_type": "MailboxFull",
"expiration_time": "2026-10-02T17:00:00.000Z",
"recipients": [
{
"email_address": "ada@example.com",
"status": "4.2.2",
"diagnostic_code": "smtp; 452 4.2.2 Mailbox full"
}
],
"ses_message_id": "0101019xxxxxxxxx-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-000000"
}
}
}| data field | Meaning |
|---|---|
| email.id | The HonkIO email id, as returned by POST /v1/emails. |
| email.to | The recipient addresses, as a list. |
| email.from | The from address the email was sent with. |
| email.ses_message_id | The email provider's id for the message, for support requests. Present on the events the provider reports. |
| email.tags | The tags you set on the email, as a list of name and value pairs; empty when there are none. |
| details.delay_type | The kind of delay the provider reported, e.g. MailboxFull, SpamDetected or TransientCommunicationFailure. |
| details.expiration_time | When the provider will stop retrying and bounce the message, ISO 8601 in UTC. |
| details.recipients | The recipients whose delivery is delayed, one object each. |
| details.recipients[].email_address | A delayed recipient's address. |
| details.recipients[].status | The SMTP status code for that recipient. |
| details.recipients[].diagnostic_code | The receiving server's own explanation, when it gave one. |
| details.ses_message_id | The same provider id as email.ses_message_id. |
email.bounced
The message bounced. A permanent bounce suppresses the address; a transient one does not.
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"type": "email.bounced",
"created": "2026-09-24T12:00:00.000Z",
"account_id": "clxxxaccountxxxxxxxxxxxxx",
"livemode": true,
"data": {
"email": {
"id": "clxxxemailxxxxxxxxxxxxxxx",
"to": [
"ada@example.com"
],
"from": "receipts@mail.acme.ca",
"ses_message_id": "0101019xxxxxxxxx-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-000000",
"tags": [
{
"name": "category",
"value": "receipt"
}
]
},
"details": {
"bounce_type": "Permanent",
"bounce_subtype": "General",
"diagnostic_code": "smtp; 550 5.1.1 user unknown",
"suppressed": true,
"transient": false,
"recipients": [
"ada@example.com"
],
"ses_message_id": "0101019xxxxxxxxx-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-000000"
}
}
}| data field | Meaning |
|---|---|
| email.id | The HonkIO email id, as returned by POST /v1/emails. |
| email.to | The recipient addresses, as a list. |
| email.from | The from address the email was sent with. |
| email.ses_message_id | The email provider's id for the message, for support requests. Present on the events the provider reports. |
| email.tags | The tags you set on the email, as a list of name and value pairs; empty when there are none. |
| details.bounce_type | Permanent, Transient or Undetermined. |
| details.bounce_subtype | The provider's finer classification, e.g. General, NoEmail or MailboxFull. |
| details.diagnostic_code | The receiving server's own explanation for the first bounced recipient, when it gave one. |
| details.suppressed | true when the address was added to your suppression list (every permanent bounce). |
| details.transient | true for a temporary bounce, which does not suppress the address. |
| details.recipients | The recipient addresses this report covers. |
| details.ses_message_id | The same provider id as email.ses_message_id. |
email.complained
A recipient marked the message as spam. The address is suppressed and recorded as an opt out.
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"type": "email.complained",
"created": "2026-09-24T12:00:00.000Z",
"account_id": "clxxxaccountxxxxxxxxxxxxx",
"livemode": true,
"data": {
"email": {
"id": "clxxxemailxxxxxxxxxxxxxxx",
"to": [
"ada@example.com"
],
"from": "receipts@mail.acme.ca",
"ses_message_id": "0101019xxxxxxxxx-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-000000",
"tags": [
{
"name": "category",
"value": "receipt"
}
]
},
"details": {
"complained_at": "2026-10-01T18:12:00.000Z",
"recipients": [
"ada@example.com"
],
"ses_message_id": "0101019xxxxxxxxx-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-000000"
}
}
}| data field | Meaning |
|---|---|
| email.id | The HonkIO email id, as returned by POST /v1/emails. |
| email.to | The recipient addresses, as a list. |
| email.from | The from address the email was sent with. |
| email.ses_message_id | The email provider's id for the message, for support requests. Present on the events the provider reports. |
| email.tags | The tags you set on the email, as a list of name and value pairs; empty when there are none. |
| details.complained_at | When the complaint was made, ISO 8601 in UTC. |
| details.recipients | The recipient addresses this report covers. |
| details.ses_message_id | The same provider id as email.ses_message_id. |
email.rejected
The message was refused before or at sending, by HonkIO's checks or by the email provider, and the charge is refunded. A rejection from our checks carries failure_code and failure_message; one reported by the provider carries reason.
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"type": "email.rejected",
"created": "2026-09-24T12:00:00.000Z",
"account_id": "clxxxaccountxxxxxxxxxxxxx",
"livemode": true,
"data": {
"email": {
"id": "clxxxemailxxxxxxxxxxxxxxx",
"from": "receipts@mail.acme.ca",
"to": [
"ada@example.com"
]
},
"details": {
"failure_code": "DOMAIN_NOT_VERIFIED",
"failure_message": "The sending domain is not verified."
}
}
}| data field | Meaning |
|---|---|
| email.id | The HonkIO email id, as returned by POST /v1/emails. |
| email.from | The from address the email was sent with. |
| email.to | The recipient addresses, as a list. |
| details.failure_code | HonkIO's code for why the email was refused or failed. |
| details.failure_message | A readable explanation of failure_code. |
| details.reason | Rejections reported by the email provider only (in place of failure_code): the provider's reason. |
| email.ses_message_id | The email provider's id for the message, for support requests. Present on the events the provider reports. |
email.failed
The email could not be sent for a reason that is not about the recipient, such as a billing or provider error, and the charge is refunded.
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"type": "email.failed",
"created": "2026-09-24T12:00:00.000Z",
"account_id": "clxxxaccountxxxxxxxxxxxxx",
"livemode": true,
"data": {
"email": {
"id": "clxxxemailxxxxxxxxxxxxxxx",
"from": "receipts@mail.acme.ca",
"to": [
"ada@example.com"
]
},
"details": {
"failure_code": "SES_ERROR",
"failure_message": "The provider did not accept the message."
}
}
}| data field | Meaning |
|---|---|
| email.id | The HonkIO email id, as returned by POST /v1/emails. |
| email.from | The from address the email was sent with. |
| email.to | The recipient addresses, as a list. |
| details.failure_code | HonkIO's code for why the email was refused or failed. |
| details.failure_message | A readable explanation of failure_code. |
email.opened
The first open of the email: the tracking pixel loaded. Only when open tracking is on; some mail clients load images automatically, so an open is a hint, not proof. Later opens fire no webhook.
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"type": "email.opened",
"created": "2026-09-24T12:00:00.000Z",
"account_id": "clxxxaccountxxxxxxxxxxxxx",
"livemode": true,
"data": {
"email": {
"id": "clxxxemailxxxxxxxxxxxxxxx",
"from": "receipts@mail.acme.ca",
"to": [
"ada@example.com"
]
},
"details": {
"ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)"
}
}
}| data field | Meaning |
|---|---|
| email.id | The HonkIO email id, as returned by POST /v1/emails. |
| email.from | The from address the email was sent with. |
| email.to | The recipient addresses, as a list. |
| details.ua | The user agent that loaded the pixel or followed the link, or null. |
email.clicked
The first click on a tracked link in the email, when click tracking is on. Later clicks fire no webhook.
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"type": "email.clicked",
"created": "2026-09-24T12:00:00.000Z",
"account_id": "clxxxaccountxxxxxxxxxxxxx",
"livemode": true,
"data": {
"email": {
"id": "clxxxemailxxxxxxxxxxxxxxx",
"from": "receipts@mail.acme.ca",
"to": [
"ada@example.com"
]
},
"details": {
"url": "https://acme.ca/orders/1042",
"ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5)"
}
}
}| data field | Meaning |
|---|---|
| email.id | The HonkIO email id, as returned by POST /v1/emails. |
| email.from | The from address the email was sent with. |
| email.to | The recipient addresses, as a list. |
| details.url | The link that was followed, as written in your email. |
| details.ua | The user agent that loaded the pixel or followed the link, or null. |
email.unsubscribed
A recipient of a commercial email unsubscribed through its link or header, and is now suppressed for that sending domain.
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"type": "email.unsubscribed",
"created": "2026-09-24T12:00:00.000Z",
"account_id": "clxxxaccountxxxxxxxxxxxxx",
"livemode": true,
"data": {
"email": {
"id": "clxxxemailxxxxxxxxxxxxxxx",
"from": "receipts@mail.acme.ca",
"to": [
"ada@example.com"
]
},
"details": {
"recipient": "ada@example.com",
"from_domain": "mail.acme.ca"
}
}
}| data field | Meaning |
|---|---|
| email.id | The HonkIO email id, as returned by POST /v1/emails. |
| email.from | The from address the email was sent with. |
| email.to | The recipient addresses, as a list. |
| details.recipient | The address that unsubscribed. |
| details.from_domain | The sending domain the address is now suppressed for. |
email.cancelled
A scheduled email was cancelled before it went out, and the charge was refunded.
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"type": "email.cancelled",
"created": "2026-09-24T12:00:00.000Z",
"account_id": "clxxxaccountxxxxxxxxxxxxx",
"livemode": true,
"data": {
"email": {
"id": "clxxxemailxxxxxxxxxxxxxxx",
"from": "receipts@mail.acme.ca",
"to": [
"ada@example.com"
]
},
"details": {
"cancelled_at": "2026-10-01T16:58:00.000Z"
}
}
}| data field | Meaning |
|---|---|
| email.id | The HonkIO email id, as returned by POST /v1/emails. |
| email.from | The from address the email was sent with. |
| email.to | The recipient addresses, as a list. |
| details.cancelled_at | When the email was cancelled, ISO 8601 in UTC. |
email.rescheduled
A scheduled email was moved to a new time.
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"type": "email.rescheduled",
"created": "2026-09-24T12:00:00.000Z",
"account_id": "clxxxaccountxxxxxxxxxxxxx",
"livemode": true,
"data": {
"email": {
"id": "clxxxemailxxxxxxxxxxxxxxx",
"from": "receipts@mail.acme.ca",
"to": [
"ada@example.com"
],
"tags": []
},
"details": {
"previous_scheduled_at": "2026-10-02T13:00:00.000Z",
"scheduled_at": "2026-10-03T13:00:00.000Z"
}
}
}| data field | Meaning |
|---|---|
| email.id | The HonkIO email id, as returned by POST /v1/emails. |
| email.from | The from address the email was sent with. |
| email.to | The recipient addresses, as a list. |
| email.tags | The tags you set on the email, as a list of name and value pairs; empty when there are none. |
| details.previous_scheduled_at | The time it was scheduled for before, ISO 8601 in UTC. |
| details.scheduled_at | The new time, ISO 8601 in UTC. |
email_domain.verified
A sending domain's DNS records checked out and it can send.
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"type": "email_domain.verified",
"created": "2026-09-24T12:00:00.000Z",
"account_id": "clxxxaccountxxxxxxxxxxxxx",
"livemode": true,
"data": {
"domain": {
"id": "clxxxdomainxxxxxxxxxxxxxx",
"domain": "mail.acme.ca",
"region": "ca-central-1"
}
}
}| data field | Meaning |
|---|---|
| domain.id | The HonkIO email domain id. |
| domain.domain | The domain name. |
| domain.region | The region the domain sends from (ca-central-1). |
email_domain.verification_failed
A sending domain failed verification, or lost it on a later re-check of its records.
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"type": "email_domain.verification_failed",
"created": "2026-09-24T12:00:00.000Z",
"account_id": "clxxxaccountxxxxxxxxxxxxx",
"livemode": true,
"data": {
"domain": {
"id": "clxxxdomainxxxxxxxxxxxxxx",
"domain": "mail.acme.ca",
"status": "temporarily_failed"
},
"details": {
"failure_reason": "DKIM records not found",
"revalidation": true
}
}
}| data field | Meaning |
|---|---|
| domain.id | The HonkIO email domain id. |
| domain.domain | The domain name. |
| domain.status | failed when another account has since verified the same domain, temporarily_failed when a re-check of an already verified domain failed; absent when a first verification failed. |
| details.failure_reason | Why verification failed, e.g. which records were not found. |
| details.revalidation | true when this was a re-check of a domain that had verified before. Absent otherwise. |
Every event carries livemode: true for a live key, false for a test key.
Automatic pause
All email goes out through one sending reputation, so an account whose recent live emails bounce or draw spam complaints at a high rate has its live email paused for 24 hours. Today that is more than 5% hard bounces over its last 200 live emails (once it has sent at least 50), or more than 0.1% complaints over its last 1,000, with at least 2 complaints: a single complaint never pauses. While paused, live sends answer 403 SENDING_PAUSED with channel email, scheduled emails wait and go out after the pause, and SMS and test keys are unaffected. We may change these figures to protect deliverability; this page shows the current ones.
The pause fires account.sending_paused with { channel: "email", paused_until, reason, bounce_rate_pct or complaint_rate_pct }, and the account owner is emailed.
Email errors
Email calls can also answer with the general codes listed in the main error table, such as VALIDATION_ERROR, INSUFFICIENT_BALANCE and NOT_FOUND.
| HTTP | Code | Meaning |
|---|---|---|
| 403 | EMAIL_DOMAIN_NOT_ALLOWED | The API key is restricted to certain sending domains, and this one isn't in its list. |
| 409 | EMAIL_DOMAIN_ALREADY_EXISTS | The domain is already on this account. |
| 409 | EMAIL_DOMAIN_IN_USE | Another account holds this domain. details.retry_after says when an unverified claim lapses; a verified domain stays with its owner. |
| 409 | EMAIL_DOMAIN_HAS_INFLIGHT | The domain still has queued, scheduled or sending email. |
| 409 | EMAIL_IDEMPOTENCY_IN_PROGRESS | A request with this Idempotency-Key is still being processed. Retry the same key shortly rather than using a new one. |
| 409 | EMAIL_TEMPLATE_ALIAS_TAKEN | Another template on this account already uses this alias. |
| 409 | EMAIL_TEMPLATE_IN_USE | A scheduled email uses this template. Cancel it, or wait until it's sent, then delete the template. |
| 413 | EMAIL_ATTACHMENT_TOO_LARGE | Attachments exceed 10 MB. |
| 422 | EMAIL_INVALID_ADDRESS | An address is not a valid email address. |
| 422 | EMAIL_DOMAIN_NOT_VERIFIED | The from domain is not a verified sending domain on this account. |
| 422 | EMAIL_DOMAIN_INVALID | Not a valid domain name. |
| 422 | EMAIL_TEST_ADDRESS_LIVE_KEY | A live key can't send to an address on test.honkio.ca; those addresses are for test keys only. |
| 422 | EMAIL_HEADER_INVALID | The from address or a header is malformed, or is one honkio sets itself. |
| 422 | EMAIL_ATTACHMENT_FETCH_FAILED | An attachment given by path could not be fetched. details.reason says why: blocked_address, http_status, timeout, too_large or network. |
| 422 | EMAIL_RENDER_FAILED | The content could not be rendered. |
| 422 | EMAIL_BATCH_TOO_LARGE | Too many recipients in one call. |
| 422 | EMAIL_COMMERCIAL_MULTI_RECIPIENT | A commercial email goes to exactly one recipient. Use a batch to reach several. |
| 422 | EMAIL_NOT_SCHEDULED | Only a scheduled email can be moved or cancelled. |
| 422 | EMAIL_TEMPLATE_NOT_PUBLISHED | The template has no published version yet. Publish it before sending with it. |
| 422 | EMAIL_TEMPLATE_VARIABLE_MISSING | A template variable has no value and no fallback. details.keys lists the missing ones. |
| 422 | EMAIL_TEMPLATE_UNDECLARED_VARIABLE | The template body uses a variable it doesn't declare. Add it to variables; details.keys lists them. |
| 422 | EMAIL_TEMPLATE_LIMIT_REACHED | This account has reached the limit of 200 email templates. Delete one to create another. |
| 451 | EMAIL_SUPPRESSED | The recipient is on your suppression list. |
| 451 | EMAIL_NO_CONSENT | Commercial email needs CASL consent on file for the recipient. |
| 451 | EMAIL_OPT_OUT_BLOCKED | The recipient unsubscribed from this sender. |
| 503 | EMAIL_ATTACHMENTS_UNCONFIGURED | Attachment storage isn't configured, so emails with attachments can't be sent right now. |
Delivery failure codes
These never come back as an HTTP response: the call already answered 201 before SES tried to send. If delivery still fails, one of these appears as failure_code on the email.failed or email.rejected webhook, and the charge is refunded.
| Code | Meaning |
|---|---|
| SES_THROTTLING | SES throttled the send; the email failed and was refunded; send it again. |
| SES_MESSAGE_REJECTED | SES rejected the message outright, for example malformed content; the email failed and was refunded; fix the content and send it again. |
| SES_DOMAIN_NOT_VERIFIED | SES had not finished verifying the from domain, even though honkio's own check passed; the email failed and was refunded; wait for verification to finish, then send it again. |
| SES_ERROR | An SES error that doesn't fit the other codes; the email failed and was refunded; send it again. |
| EMAIL_ATTACHMENTS_UNAVAILABLE | The stored attachment bytes could not be read at send time; the email failed and was refunded; send it again. |
Migrating from Resend
For most integrations, change the base URL and the key. With the SDK, import Honkio from @honkio/node in place of Resend.
| Resend | HonkIO | Notes |
|---|---|---|
| https://api.resend.com | https://api.honkio.ca/v1 | Keys start with mk_live_ or mk_test_. |
| from, to, cc, bcc, reply_to | same | At most 50 recipients per call. |
| subject, html, text, headers | same | |
| attachments[].content / path / filename / content_type / content_id | same | content_base64 is accepted as an alias of content. |
| tags [{ name, value }] | same | |
| scheduled_at | scheduled_at | ISO 8601 only. |
| template { id, variables } | same | Templates are stored in HonkIO; triple braces are escaped. |
| POST /emails/batch | POST /v1/emails/batch | Array of up to 100, or the template object for up to 500. |
| Idempotency-Key | same | |
| react | not available | Render your React email to HTML first. |
| audiences, broadcasts, inbound | not available |
Known differences
- scheduled_at takes an ISO 8601 date-time. Natural language such as in 1 hour is refused.
- Triple brace variables are HTML escaped, the same as double braces.
- Commercial email follows CASL: consent on file, one recipient, an unsubscribe footer.
- Responses keep HonkIO's extra fields, such as status, livemode and cost_cents, alongside the id Resend clients read.
- A single send reaches at most 50 recipients across to, cc and bcc.
HonkIO