Skip to Content
Emails

Emails

Transactional email: your app’s password resets, receipts and magic links. They go out from the same verified domain and draw on the same monthly allowance as your campaigns, so there is no second provider and no second reputation to manage.

The request shape is Resend-compatible, so an existing integration usually only needs its base URL, key and from changed.

The Email object

{ "id": "eml_0dz4k9x2v7q1", "object": "email", "from": "Acme <noreply@yourdomain.com>", "to": ["jane@acme.com"], "reply_to": null, "list_unsubscribe": null, "subject": "Reset your password", "status": "delivered", "error": null, "tags": { "type": "password-reset" }, "sandbox": false, "created_at": "2026-08-21T09:00:00.000Z", "sent_at": "2026-08-21T09:00:01.000Z", "delivered_at": "2026-08-21T09:00:03.000Z", "bounced_at": null, "complained_at": null }

Status

status walks queued then sent then delivered, or ends at one of bounced, complained, failed, suppressed.

StatusMeaning
queuedAccepted and waiting for the worker.
sentHanded to the mail provider. Not yet delivered.
deliveredThe receiving server accepted it.
bouncedCame back. The address may now be suppressed.
complainedMarked as spam by the recipient. The address is suppressed.
failedNever left. error says why.
suppressedBlocked before sending, because the address is unmailable.

Delivery is asynchronous. A transactional send is a top-priority worker job and never waits behind a campaign drain, so it normally completes in seconds.

Send an email

POST/v1/emails
curl -X POST https://go.day3.app/api/v1/emails \ -H "Authorization: Bearer $DAY3_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: pwreset-user-8412-1755764400" \ -d '{ "from": "Acme <noreply@yourdomain.com>", "to": ["jane@acme.com"], "subject": "Reset your password", "html": "<p>Click to reset.</p>", "text": "Click to reset.", "reply_to": "support@yourdomain.com", "headers": { "X-Entity-Ref-ID": "pwreset-8412" }, "tags": { "type": "password-reset" } }'

Body

FieldTypeRequiredNotes
fromstringYesA bare address or "Name <addr>". The domain must be verified.
tostring or string[]YesUp to 50 addresses. See below.
subjectstringYesControl characters are rejected.
htmlstringOne ofHTML body.
textstringOne ofPlain-text body. Send both when you can.
reply_tostringNoAny address. Does not have to be on your domain.
headersobjectNoUp to 20 custom headers. Reserved names are rejected.
tagsobjectNoFlat string map, for your own filtering.

At least one of html or text is required.

The response is the Email object with status: "queued", returned as 200.

Always set Idempotency-Key on a send. A retried password reset that sends twice is a support ticket. See Idempotency.

from must be on a verified domain

Verify the domain in the app under Sending. After that, any local part works without pre-creating a sender: noreply@, receipts@, billing@.

An unverified domain is 403 domain_not_verified. Verify it rather than retrying.

The day3 test address

Every new organization is given one pre-verified address on a day3-owned domain, so your first API send needs no DNS at all. It looks like this:

acme@sandbox.day3.app

The local part comes from your organization name. The exact address is shown in the app under Sending, on the card above the domain list.

A send from the test address must be a sandbox send. It therefore works while your account is in sandbox mode, to members of your own organization, and it stops working the moment you upgrade. Anything else is 403 domain_not_verified with a message saying so, including a send to your own teammate once you are on a paid plan.

That rule is the whole basis of the address. It sits on a sending identity shared by every day3 account, so its reputation is not yours alone to spend. Sandbox already restricts recipients to people who asked for the mail and caps the volume, so the address is allowed exactly as far as those limits reach and no further. The check fails closed: anything it cannot prove is a sandbox send is refused.

Two more consequences worth knowing before you build on it:

  • The footer carries day3’s postal address, not yours, because the mail leaves our domain.
  • It does not count as your verified domain anywhere in the app. Ticking that step would retire the one piece of setup that lets you reach real subscribers.

So use it to get an integration working on day one, and verify your own domain before you upgrade. The only code change is from.

Multiple recipients

to accepts a string or an array of up to 50 addresses. An array is one message whose To header lists them all, not 50 separate emails. Everyone sees everyone.

Addresses are canonicalized, meaning trimmed and lowercased, then de-duplicated. Each surviving address counts against your monthly allowance.

If you need 50 people not to see each other, send 50 requests.

Custom headers

Up to 20. Anything day3 derives from the body or needs for delivery is rejected with 400:

  • Body-derived: From, To, Subject, Reply-To and friends.
  • MIME plumbing.
  • Authentication and trace headers: DKIM-Signature, Authentication-Results, ARC-*, Received, Sender, Resent-*.
  • The unsubscribe headers.
  • Our own X-Account-ID and X-Transactional-Email-ID.
  • Anything starting with X-SES-.

Limits

LimitValue
Recipients per message50
Custom headers20
Total size of html + text + headers + tags1.5 MB of UTF-8
Rate120 sends per minute per organization

Control characters are rejected in subject, header values and addresses, and a display name may not contain <, >, " or \. That is header-injection defence, not style preference.

Retrieve an email

GET/v1/emails/{email_id}

Returns the Email object plus events, the delivery timeline fed by the provider:

{ "id": "eml_0dz4k9x2v7q1", "object": "email", "status": "delivered", "events": [ { "type": "sent", "created_at": "2026-08-21T09:00:01.000Z" }, { "type": "delivery", "created_at": "2026-08-21T09:00:03.000Z" } ] }

Bodies are pruned after 30 days. The metadata row is kept forever, and the API reports the content as expired rather than returning an empty string.

List emails

GET/v1/emails

Newest first, cursor-paginated. Filter with ?status=queued|sent|delivered|bounced|complained|failed|suppressed.

curl "https://go.day3.app/api/v1/emails?status=bounced&limit=100" \ -H "Authorization: Bearer $DAY3_API_KEY"

How suppression works here

Transactional mail deliberately follows different rules from campaigns:

  • An unsubscribe does not block a transactional send. Opting out of a newsletter must never stop a password reset arriving.
  • A hard bounce, a complaint, or a provider suppression does block it, with 400 email_suppressed. Do not retry: the address is unmailable and trying again damages your reputation.

Bounces and complaints on transactional mail feed the suppression list and count towards the account’s reputation auto-pause exactly like campaign sends do. This is not a separate reputation.

Sandbox mode

On a free plan, sends are real but restricted to addresses of members of your own organization, on a small monthly allowance. Responses carry "sandbox": true.

SituationResponse
Recipient outside your organization403 sandbox_recipient_not_allowed
Allowance exhausted403 plan_limit_reached

Upgrading lifts both with no code change. See Authentication.

Errors

StatusCodeCause
400invalid_requestMissing required field, a reserved header, or over a size limit.
400invalid_emailAn address is not valid.
400email_suppressedThe address hard-bounced, complained, or is provider-suppressed.
403domain_not_verifiedThe from domain is not verified, or it is the test address on a send that is not a sandbox send.
403sandbox_recipient_not_allowedFree plan, recipient outside the organization.
403plan_limit_reachedMonthly send allowance exhausted.
403sending_disabledSending is off for this account.
429rate_limit_exceededOver 120 sends per minute. Honour Retry-After.

Knowing what happened, without polling

Polling GET /emails/{id} works, but webhooks are better: day3 POSTs email.delivered, email.bounced, email.complained and suppression.created to your endpoint as they happen. suppression.created is the one that keeps your own database honest about which addresses are dead.

Last updated on