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.

bash
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.

javascript
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:

javascript
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.

RecordTypeName (example)Purpose
DKIMTXThonkio1._domainkey.mail.acme.caSigns your mail. Required.
MXMXsend.mail.acme.caReturn path for bounces, on the send. subdomain. Required.
SPFTXTsend.mail.acme.caAuthorizes the return path, on the send. subdomain. Required.
DMARCTXT_dmarc.mail.acme.caRecommended, not checked. Start with p=none.
bash
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.

FieldMeaning
fromSender, optionally with a name. Its domain must be a verified sending domain on your account.
to, cc, bccA string or an array; at most 50 recipients across to, cc and bcc.
reply_toA string or an array.
subject, html, textSubject up to 998 characters, and at least one of html or text. Omit all three when you send a template.
variablesValues to substitute into placeholders in subject, html and text. Ignored when template is set; a template's own variables go in template.variables instead.
templateSend a stored template by id or alias, with variables.
attachmentsfilename plus one of content (base64), content_base64 or path (an HTTPS URL). content_id embeds the file inline. 10 MB in total.
tagsUp 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.
headersExtra email headers, sent as given.
scheduled_atAn ISO 8601 date-time, 1 minute to 30 days ahead.
is_commercialFalse by default. True marks a commercial message under CASL; see below.
trackingPer email opens and clicks, overriding the domain's setting.
bash
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.

bash
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.

bash
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.

text
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.

bash
# 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:

AddressWhat happens
delivered@test.honkio.caDelivered.
bounced@test.honkio.caHard bounce; the address is added to your suppressions.
complained@test.honkio.caDelivered, then a spam complaint; the address is suppressed and opted out.
delayed@test.honkio.caA delivery delay, then delivered.
*@test.honkio.caAny 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.

bash
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.

bash
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.

FieldMeaning
idThe 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.
typeThe event name, e.g. message.received. The same value is sent in the X-HonkIO-Event header.
createdWhen the event happened, ISO 8601 in UTC with milliseconds. A retry or a replay keeps the original value.
account_idThe HonkIO account the event belongs to.
livemodetrue when a live key's action caused the event, false for a test key's simulation. Account and phone number events are always true.
dataThe 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.

json
{
  "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 fieldMeaning
email.idThe HonkIO email id, as returned by POST /v1/emails.
email.fromThe from address the email was sent with.
email.toThe recipient addresses, as a list.
email.subjectThe subject line.
email.tagsThe tags you set on the email, as a list of name and value pairs; empty when there are none.
email.statusqueued, or scheduled when the email waits for scheduled_at. Absent on emails sent as part of a batch.
email.batch_idEmails sent through POST /v1/emails/batch only: the batch the email belongs to.
email.is_commercialWhether you marked the email as commercial (CASL), which decides the unsubscribe footer and header.

email.sent

The email provider accepted the message for delivery.

json
{
  "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 fieldMeaning
email.idThe HonkIO email id, as returned by POST /v1/emails.
email.fromThe from address the email was sent with.
email.toThe recipient addresses, as a list.
email.ses_message_idThe email provider's id for the message, for support requests. Present on the events the provider reports.
email.subjectThe subject line.
email.tagsThe 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.

json
{
  "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 fieldMeaning
email.idThe HonkIO email id, as returned by POST /v1/emails.
email.toThe recipient addresses, as a list.
email.fromThe from address the email was sent with.
email.ses_message_idThe email provider's id for the message, for support requests. Present on the events the provider reports.
email.tagsThe tags you set on the email, as a list of name and value pairs; empty when there are none.
details.delivered_atWhen the receiving server accepted the message, ISO 8601 in UTC.
details.smtp_responseThe receiving server's SMTP answer, as given.
details.processing_time_msHow long delivery took from acceptance, in milliseconds.
details.recipientsThe recipient addresses this report covers.
details.ses_message_idThe 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.

json
{
  "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 fieldMeaning
email.idThe HonkIO email id, as returned by POST /v1/emails.
email.toThe recipient addresses, as a list.
email.fromThe from address the email was sent with.
email.ses_message_idThe email provider's id for the message, for support requests. Present on the events the provider reports.
email.tagsThe tags you set on the email, as a list of name and value pairs; empty when there are none.
details.delay_typeThe kind of delay the provider reported, e.g. MailboxFull, SpamDetected or TransientCommunicationFailure.
details.expiration_timeWhen the provider will stop retrying and bounce the message, ISO 8601 in UTC.
details.recipientsThe recipients whose delivery is delayed, one object each.
details.recipients[].email_addressA delayed recipient's address.
details.recipients[].statusThe SMTP status code for that recipient.
details.recipients[].diagnostic_codeThe receiving server's own explanation, when it gave one.
details.ses_message_idThe same provider id as email.ses_message_id.

email.bounced

The message bounced. A permanent bounce suppresses the address; a transient one does not.

json
{
  "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 fieldMeaning
email.idThe HonkIO email id, as returned by POST /v1/emails.
email.toThe recipient addresses, as a list.
email.fromThe from address the email was sent with.
email.ses_message_idThe email provider's id for the message, for support requests. Present on the events the provider reports.
email.tagsThe tags you set on the email, as a list of name and value pairs; empty when there are none.
details.bounce_typePermanent, Transient or Undetermined.
details.bounce_subtypeThe provider's finer classification, e.g. General, NoEmail or MailboxFull.
details.diagnostic_codeThe receiving server's own explanation for the first bounced recipient, when it gave one.
details.suppressedtrue when the address was added to your suppression list (every permanent bounce).
details.transienttrue for a temporary bounce, which does not suppress the address.
details.recipientsThe recipient addresses this report covers.
details.ses_message_idThe 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.

json
{
  "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 fieldMeaning
email.idThe HonkIO email id, as returned by POST /v1/emails.
email.toThe recipient addresses, as a list.
email.fromThe from address the email was sent with.
email.ses_message_idThe email provider's id for the message, for support requests. Present on the events the provider reports.
email.tagsThe tags you set on the email, as a list of name and value pairs; empty when there are none.
details.complained_atWhen the complaint was made, ISO 8601 in UTC.
details.recipientsThe recipient addresses this report covers.
details.ses_message_idThe 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.

json
{
  "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 fieldMeaning
email.idThe HonkIO email id, as returned by POST /v1/emails.
email.fromThe from address the email was sent with.
email.toThe recipient addresses, as a list.
details.failure_codeHonkIO's code for why the email was refused or failed.
details.failure_messageA readable explanation of failure_code.
details.reasonRejections reported by the email provider only (in place of failure_code): the provider's reason.
email.ses_message_idThe 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.

json
{
  "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 fieldMeaning
email.idThe HonkIO email id, as returned by POST /v1/emails.
email.fromThe from address the email was sent with.
email.toThe recipient addresses, as a list.
details.failure_codeHonkIO's code for why the email was refused or failed.
details.failure_messageA 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.

json
{
  "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 fieldMeaning
email.idThe HonkIO email id, as returned by POST /v1/emails.
email.fromThe from address the email was sent with.
email.toThe recipient addresses, as a list.
details.uaThe 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.

json
{
  "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 fieldMeaning
email.idThe HonkIO email id, as returned by POST /v1/emails.
email.fromThe from address the email was sent with.
email.toThe recipient addresses, as a list.
details.urlThe link that was followed, as written in your email.
details.uaThe 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.

json
{
  "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 fieldMeaning
email.idThe HonkIO email id, as returned by POST /v1/emails.
email.fromThe from address the email was sent with.
email.toThe recipient addresses, as a list.
details.recipientThe address that unsubscribed.
details.from_domainThe sending domain the address is now suppressed for.

email.cancelled

A scheduled email was cancelled before it went out, and the charge was refunded.

json
{
  "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 fieldMeaning
email.idThe HonkIO email id, as returned by POST /v1/emails.
email.fromThe from address the email was sent with.
email.toThe recipient addresses, as a list.
details.cancelled_atWhen the email was cancelled, ISO 8601 in UTC.

email.rescheduled

A scheduled email was moved to a new time.

json
{
  "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 fieldMeaning
email.idThe HonkIO email id, as returned by POST /v1/emails.
email.fromThe from address the email was sent with.
email.toThe recipient addresses, as a list.
email.tagsThe tags you set on the email, as a list of name and value pairs; empty when there are none.
details.previous_scheduled_atThe time it was scheduled for before, ISO 8601 in UTC.
details.scheduled_atThe new time, ISO 8601 in UTC.

email_domain.verified

A sending domain's DNS records checked out and it can send.

json
{
  "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 fieldMeaning
domain.idThe HonkIO email domain id.
domain.domainThe domain name.
domain.regionThe 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.

json
{
  "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 fieldMeaning
domain.idThe HonkIO email domain id.
domain.domainThe domain name.
domain.statusfailed 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_reasonWhy verification failed, e.g. which records were not found.
details.revalidationtrue 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.

HTTPCodeMeaning
403EMAIL_DOMAIN_NOT_ALLOWEDThe API key is restricted to certain sending domains, and this one isn't in its list.
409EMAIL_DOMAIN_ALREADY_EXISTSThe domain is already on this account.
409EMAIL_DOMAIN_IN_USEAnother account holds this domain. details.retry_after says when an unverified claim lapses; a verified domain stays with its owner.
409EMAIL_DOMAIN_HAS_INFLIGHTThe domain still has queued, scheduled or sending email.
409EMAIL_IDEMPOTENCY_IN_PROGRESSA request with this Idempotency-Key is still being processed. Retry the same key shortly rather than using a new one.
409EMAIL_TEMPLATE_ALIAS_TAKENAnother template on this account already uses this alias.
409EMAIL_TEMPLATE_IN_USEA scheduled email uses this template. Cancel it, or wait until it's sent, then delete the template.
413EMAIL_ATTACHMENT_TOO_LARGEAttachments exceed 10 MB.
422EMAIL_INVALID_ADDRESSAn address is not a valid email address.
422EMAIL_DOMAIN_NOT_VERIFIEDThe from domain is not a verified sending domain on this account.
422EMAIL_DOMAIN_INVALIDNot a valid domain name.
422EMAIL_TEST_ADDRESS_LIVE_KEYA live key can't send to an address on test.honkio.ca; those addresses are for test keys only.
422EMAIL_HEADER_INVALIDThe from address or a header is malformed, or is one honkio sets itself.
422EMAIL_ATTACHMENT_FETCH_FAILEDAn attachment given by path could not be fetched. details.reason says why: blocked_address, http_status, timeout, too_large or network.
422EMAIL_RENDER_FAILEDThe content could not be rendered.
422EMAIL_BATCH_TOO_LARGEToo many recipients in one call.
422EMAIL_COMMERCIAL_MULTI_RECIPIENTA commercial email goes to exactly one recipient. Use a batch to reach several.
422EMAIL_NOT_SCHEDULEDOnly a scheduled email can be moved or cancelled.
422EMAIL_TEMPLATE_NOT_PUBLISHEDThe template has no published version yet. Publish it before sending with it.
422EMAIL_TEMPLATE_VARIABLE_MISSINGA template variable has no value and no fallback. details.keys lists the missing ones.
422EMAIL_TEMPLATE_UNDECLARED_VARIABLEThe template body uses a variable it doesn't declare. Add it to variables; details.keys lists them.
422EMAIL_TEMPLATE_LIMIT_REACHEDThis account has reached the limit of 200 email templates. Delete one to create another.
451EMAIL_SUPPRESSEDThe recipient is on your suppression list.
451EMAIL_NO_CONSENTCommercial email needs CASL consent on file for the recipient.
451EMAIL_OPT_OUT_BLOCKEDThe recipient unsubscribed from this sender.
503EMAIL_ATTACHMENTS_UNCONFIGUREDAttachment 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.

CodeMeaning
SES_THROTTLINGSES throttled the send; the email failed and was refunded; send it again.
SES_MESSAGE_REJECTEDSES rejected the message outright, for example malformed content; the email failed and was refunded; fix the content and send it again.
SES_DOMAIN_NOT_VERIFIEDSES 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_ERRORAn SES error that doesn't fit the other codes; the email failed and was refunded; send it again.
EMAIL_ATTACHMENTS_UNAVAILABLEThe 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.

ResendHonkIONotes
https://api.resend.comhttps://api.honkio.ca/v1Keys start with mk_live_ or mk_test_.
from, to, cc, bcc, reply_tosameAt most 50 recipients per call.
subject, html, text, headerssame
attachments[].content / path / filename / content_type / content_idsamecontent_base64 is accepted as an alias of content.
tags [{ name, value }]same
scheduled_atscheduled_atISO 8601 only.
template { id, variables }sameTemplates are stored in HonkIO; triple braces are escaped.
POST /emails/batchPOST /v1/emails/batchArray of up to 100, or the template object for up to 500.
Idempotency-Keysame
reactnot availableRender your React email to HTML first.
audiences, broadcasts, inboundnot 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.